Checkpoint of Word refactor

This commit is contained in:
Christopher T. Johnson
2024-03-21 09:42:09 -04:00
parent efec40176f
commit d9fefb99dd
2 changed files with 500 additions and 63 deletions

48
deftest.py Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import os
import sys
from PyQt6.QtCore import QResource
from PyQt6.QtGui import QFontDatabase
from PyQt6.QtSql import QSqlDatabase, QSqlQuery
from PyQt6.QtWidgets import QApplication
from lib import Definition, Word
from lib.sounds import SoundOff
from lib.utils import query_error
def main() -> int:
db = QSqlDatabase()
db = db.addDatabase("QSQLITE")
db.setDatabaseName("test.db")
if not db.open():
raise Exception(db.lastError())
app = QApplication(sys.argv)
#
# Setup resources
#
if not QResource.registerResource(
os.path.join(os.path.dirname(__file__), "ui/resources.rcc"), "/"
):
raise Exception("Unable to register resources.rcc")
QFontDatabase.addApplicationFont(
":/fonts/opendyslexic/OpenDyslexic-Regular.otf"
)
query = QSqlQuery()
if not query.exec(
"CREATE TABLE IF NOT EXISTS words "
"(word_id INTEGER PRIMARY KEY AUTOINCREMENT, word TEXT, definition TEXT)"
):
query_error(query)
word = Word("lady")
snd = SoundOff()
widget = Definition(word) # noqa: F841
widget.pronounce.connect(snd.playSound)
widget.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,27 +1,238 @@
import json import json
import requests import re
from typing import Any, Dict, List, Optional, Self, Type, cast
import requests
from PyQt6.QtCore import QPoint, QRect, Qt, pyqtSignal
from PyQt6.QtGui import (
QBrush,
QColor,
QFont,
QFontDatabase,
QFontMetrics,
QMouseEvent,
QPainter,
QPaintEvent,
QTextFormat,
QTextOption,
)
from PyQt6.QtSql import QSqlQuery from PyQt6.QtSql import QSqlQuery
from typing import Type, Self, Dict, Any, Optional from PyQt6.QtWidgets import QWidget
from lib import query_error from lib import query_error
API = "https://api.dictionaryapi.dev/api/v2/entries/en/{word}" 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" MWAPI = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}?key=51d9df34-ee13-489e-8656-478c215e846c"
class Word: class Word:
_instance = None _instance = None
_words: Dict[str,str] = {} _words: Dict[str, str] = {}
_current: Optional[Dict[str,Any]] = None _current: Optional[Dict[str, Any]] = None
_currentWord: Optional[str] = None
def __new__(cls: Type[Self], word:str ) -> Self:
class Fragment:
_type: str
_text: str
_font: QFont
_audio: str
_align: QTextOption
_rect: QRect
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)
) -> None:
self._type = t # or 'container'
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
else:
raise Exception("Bad Value")
return
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) -> None:
self._audio = audio
return
def setAlign(self, align:QTextOption) -> None:
self._align = align
return
def setRect(self,rect:QRect) -> None:
self._rect = rect
return
def type(self) -> str:
return self._type
def text(self) -> str:
return self._text
def font(self) -> QFont:
return self._font
def audio(self) -> str:
return self._audio
def align(self) -> QTextOption:
return self._align
def rect(self) -> QRect:
return self._rect
class Line:
_maxHeight: int
_leading: int
_baseLine: int
_fragments: List['Word.Fragment']
def __init__(self) -> None:
self._maxHeight = -1
self._baseLine = -1
self._leading = -1
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] = []
#
# Break the text into parts based on brace markup
#
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)
italic = QFont(frag.font())
italic.setItalic(True)
script = QFont(frag.font())
script.setPixelSize(int(script.pixelSize() / 4))
while len(parts) > 0:
if parts[0] == '{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
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)
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())
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()
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
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] = []
def __new__(cls: Type[Self], word: str) -> Self: # flycheck: ignore
if cls._instance: if cls._instance:
return cls._instance return cls._instance
cls._instance = super(Word, cls).__new__(cls) cls._instance = super(Word, cls).__new__(cls)
return cls._instance return cls._instance
def __init__(self, word: str) -> None: def __init__(self, word: str) -> None:
print(f"Word == {word}") self._currentWord = word
# #
# Have we already retrieved this word? # Have we already retrieved this word?
# #
@@ -31,8 +242,7 @@ class Word:
except KeyError: except KeyError:
pass pass
query = QSqlQuery() query = QSqlQuery()
query.prepare("SELECT * FROM words " query.prepare("SELECT * FROM words " "WHERE word = :word")
"WHERE word = :word")
query.bindValue(":word", word) query.bindValue(":word", word)
if not query.exec(): if not query.exec():
query_error(query) query_error(query)
@@ -50,18 +260,25 @@ class Word:
# if there is a "hom" entry, then that will be appended to meta.id # if there is a "hom" entry, then that will be appended to meta.id
# word = "lady", hom=1, meta.id = "lady:1"; # word = "lady", hom=1, meta.id = "lady:1";
# #
print(response.content.decode('utf-8')) print(response.content.decode("utf-8"))
self._words[word] = json.dumps(data[0]) self._words[word] = json.dumps(data[0])
self._current = data[0] self._current = data[0]
query.prepare("INSERT INTO words " query.prepare(
"(word, definition) " "INSERT INTO words "
"VALUES (:word, :definition)") "(word, definition) "
"VALUES (:word, :definition)"
)
query.bindValue(":word", word) query.bindValue(":word", word)
query.bindValue(":definition", self._words[word]) query.bindValue(":definition", self._words[word])
if not query.exec(): if not query.exec():
query_error(query) query_error(query)
return return
def get_html(self) -> str|None:
def getCurrent(self) -> str:
assert self._currentWord is not None
return self._currentWord
def get_html(self) -> str | None:
if not self._current: if not self._current:
return None return None
if "meta" in self._current.keys(): if "meta" in self._current.keys():
@@ -69,76 +286,248 @@ class Word:
else: else:
return self.apidictionary_html() return self.apidictionary_html()
def mw_html(self) -> str: def get_def(self) -> List[Line] | None:
if not self._current:
return None
if "meta" in self._current.keys():
return self.mw_def()
else:
return None
def sound_url(prs:Dict[str,Any]) -> str|None: def mw_def(self) -> List[Line]:
base = 'https://media.merriam-webster.com/audio/prons/en/us/ogg' if len(self._lines) > 0:
if 'sound' not in prs.keys(): return self._lines
return None
audio = prs['sound']['audio']
if audio.startswith('bix'):
url = base + '/bix/'
elif audio.startswith('gg'):
url = base + '/gg/'
elif audio[0] not in "abcdefghijklmnopqrstuvwxyz":
url = base + '/number/'
else:
url = base + '/' + audio[0] + '/'
url += audio + '.ogg'
return url
def parse_sn(sn:str, old:str) -> str:
return sn
assert self._current is not None assert self._current is not None
word = self._current['hwi']['hw'] line = self.Line()
label = self._current['fl'] headerFont = QFontDatabase.font("OpenDyslexic", None, 10)
html = f"<h1 class=\"def-word\">{word} <span class=\"def-label\">{label}</span></h1>\n" headerFont.setPixelSize(48)
headerFont.setWeight(QFont.Weight.Bold)
labelFont = QFontDatabase.font("OpenDyslexic", None, 10)
labelFont.setPixelSize(32)
phonicFont = QFontDatabase.font("Gentium", None, 10)
phonicFont.setPixelSize(32)
boldFont = QFontDatabase.font("OpenDyslexic", None, 10)
boldFont.setPixelSize(24)
boldFont.setBold(True)
textFont = QFontDatabase.font("OpenDyslexic", None, 10)
textFont.setPixelSize(24)
line.addFragment(self._current["hwi"]["hw"], headerFont)
line.addFragment(self._current["fl"], labelFont, color="#4a7d95")
self._lines.append(line)
if "vrs" in self._current.keys(): if "vrs" in self._current.keys():
html += "<ol class=\"def-vars\'>\n" line = self.Line()
for vrs in self._current["vrs"]:
line.addFragment(vrs["va"], labelFont)
self._lines.append(line)
if "prs" in self._current["hwi"].keys():
line = self.Line()
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",
)
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
)
self._lines.append(line)
return self._lines
def 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:
def parse_sn(sn: str, old: str) -> str:
return sn
assert self._current is not None
#
# Create the header, base word and its label
#
word = self._current["hwi"]["hw"]
label = self._current["fl"]
html = f'<h1 class="def-word">{word} <span class="def-label">{label}</span></h1>\n'
#
# If there are variants, then add them in an unordered list.
# CSS will make it pretty
#
if "vrs" in self._current.keys():
html += "<ul class=\"def-vrs'>\n"
html += "<li>" html += "<li>"
html += "</li>\n<li>".join([vrs['va'] for vrs in self._current['vrs']]) html += "</li>\n<li>".join(
html += "</li>\n</ol>\n" [vrs["va"] for vrs in self._current["vrs"]]
if 'prs' in self._current['hwi'].keys(): )
html += "</li>\n</ul>\n"
#
# If there is a pronunciation section, create it
#
if "prs" in self._current["hwi"].keys():
tmp = [] tmp = []
for prs in self._current['hwi']['prs']: for prs in self._current["hwi"]["prs"]:
url = sound_url(prs) url = self.sound_url(prs)
how = prs['mw'] how = prs["mw"]
if url: if url:
tmp.append(f'<a href="{url}">\\{how}\\</a>') tmp.append(f'<a href="{url}">\\{how}\\</a>')
else: else:
tmp.append(f'\\{how}\\') tmp.append(f"\\{how}\\")
html += '<span class="def-phonetic">' html += '<span class="def-phonetic">'
html += '</span><span="def-phonetic">'.join(tmp) html += '</span><span="def-phonetic">'.join(tmp)
html += "</span>\n" html += "</span>\n"
if 'ins' in self._current.keys():
html += "<h2 class=\"def-word\">" #
html += ', '.join([ins['if'] for ins in self._current['ins']]) # If there are inflections, create a header for that.
#
if "ins" in self._current.keys():
html += '<h2 class="def-word">'
html += ", ".join([ins["if"] for ins in self._current["ins"]])
html += "</h2>\n" html += "</h2>\n"
#
# Start creating the definition section
#
html += "<ul class='def-outer'>\n" html += "<ul class='def-outer'>\n"
for meaning in self._current['def']: for meaning in self._current["def"]:
html += f"<li>{meaning['vd']}\n" html += f"<li>{meaning['vd']}\n"
html += "<ul class=\"def-inner\">\n" html += '<ul class="def-inner">\n'
label = '' label = ""
for sseq in meaning['sseq']: for sseq in meaning["sseq"]:
for sense in sseq: for sense in sseq:
label = parse_sn(sense[1]['sn'], label) label = parse_sn(sense[1]["sn"], label)
sls = '' sls = ""
if 'sls' in sense[1].keys(): if "sls" in sense[1].keys():
sls = ', '.join(sense[1]['sls']) sls = ", ".join(sense[1]["sls"])
sls = f"<span class=\"def-sls\">{sls}</span> " sls = f'<span class="def-sls">{sls}</span> '
for dt in sense[1]['dt']: for dt in sense[1]["dt"]:
if dt[0] == 'text': if dt[0] == "text":
html += f"<li class=\"def-text\"><span class=\"def-sn\">{label}</span>{sls}{dt[1]}</li>\n" html += f'<li class="def-text"><span class="def-sn">{label}</span>{sls}{dt[1]}</li>\n'
elif dt[0] == 'vis': elif dt[0] == "vis":
for vis in dt[1]: for vis in dt[1]:
html += f"<li class=\"def-vis\">{vis['t']}</li>\n" html += (
f"<li class=\"def-vis\">{vis['t']}</li>\n"
)
else: else:
print(f"Do something with {dt[0]}") print(f"Do something with {dt[0]}")
html += "</ul>\n" html += "</ul>\n"
html += "</ul>\n" html += "</ul>\n"
return html return html
def apidictionary_html(self) ->str: def apidictionary_html(self) -> str:
html = "" html = ""
return html return html
class Definition(QWidget):
pronounce = pyqtSignal(str)
_word: str
_lines: List[Word.Line]
_buttons: List[QRect]
def __init__(self, w: Word, *args: Any, **kwargs: Any) -> None:
super(Definition, self).__init__(*args, **kwargs)
self._word = w.getCurrent()
lines = w.get_def()
assert lines is not None
self._lines = lines
self._buttons = []
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)
base += line.getLeading()
return
_downRect: QRect | None = None
def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
if not event:
return super().mousePressEvent(event)
for rect in self._buttons:
if rect.contains(event.pos()):
self._downRect = rect
return
return super().mousePressEvent(event)
def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None:
if not event:
return super().mouseReleaseEvent(event)
if self._downRect is not None and self._downRect.contains(event.pos()):
self.pronounce.emit(
"https://media.merriam-webster.com/audio/prons/en/us/ogg/a/await001.ogg"
)
self._downRect = None
return
self._downRect = None
return super().mouseReleaseEvent(event)
def paintEvent(self, event: 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:
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()
base += line.getLeading()
painter.restore()
return