Add ability to storage images in the page

This commit is contained in:
Miguel Jacq 2025-11-04 15:57:41 +11:00
parent 7548f33de4
commit 74a75eadcb
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 331 additions and 5 deletions

View file

@ -6,6 +6,7 @@
* Add plaintext SQLite3 Export option * Add plaintext SQLite3 Export option
* Add Backup option (database remains encrypted with SQLCipher) * Add Backup option (database remains encrypted with SQLCipher)
* Add ability to run VACUUM (compact) on the database in settings * Add ability to run VACUUM (compact) on the database in settings
* Add ability to store images in the page
# 0.1.8 # 0.1.8

View file

@ -470,7 +470,7 @@ class DBManager:
""" """
try: try:
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(f"VACUUM") cur.execute("VACUUM")
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Error: {e}")

View file

@ -1,17 +1,34 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import base64, re
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor, QColor,
QDesktopServices, QDesktopServices,
QFont, QFont,
QFontDatabase, QFontDatabase,
QImage,
QImageReader,
QPixmap,
QTextCharFormat, QTextCharFormat,
QTextCursor, QTextCursor,
QTextFrameFormat, QTextFrameFormat,
QTextListFormat, QTextListFormat,
QTextBlockFormat, QTextBlockFormat,
QTextImageFormat,
QTextDocument,
)
from PySide6.QtCore import (
Qt,
QUrl,
Signal,
Slot,
QRegularExpression,
QBuffer,
QByteArray,
QIODevice,
) )
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
from PySide6.QtWidgets import QTextEdit from PySide6.QtWidgets import QTextEdit
@ -22,6 +39,8 @@ class Editor(QTextEdit):
_CODE_BG = QColor(245, 245, 245) _CODE_BG = QColor(245, 245, 245)
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
_HEADING_SIZES = (24.0, 18.0, 14.0) _HEADING_SIZES = (24.0, 18.0, 14.0)
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -129,6 +148,291 @@ class Editor(QTextEdit):
finally: finally:
self._linkifying = False self._linkifying = False
def _to_qimage(self, obj) -> QImage | None:
if isinstance(obj, QImage):
return None if obj.isNull() else obj
if isinstance(obj, QPixmap):
qi = obj.toImage()
return None if qi.isNull() else qi
if isinstance(obj, (bytes, bytearray)):
qi = QImage.fromData(obj)
return None if qi.isNull() else qi
return None
def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QIODevice.WriteOnly)
img.save(buf, fmt.upper())
b64 = base64.b64encode(bytes(ba)).decode("ascii")
mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
return f"data:{mime};base64,{b64}"
def _image_name_to_qimage(self, name: str) -> QImage | None:
res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
return res if isinstance(res, QImage) and not res.isNull() else None
def to_html_with_embedded_images(self) -> str:
"""
Return the document HTML with all image src's replaced by data: URLs,
so it is self-contained for storage in the DB.
"""
# 1) Walk the document collecting name -> data: URL
name_to_data = {}
cur = QTextCursor(self.document())
cur.movePosition(QTextCursor.Start)
while True:
cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
fmt = cur.charFormat()
if fmt.isImageFormat():
imgfmt = QTextImageFormat(fmt)
name = imgfmt.name()
if name and name not in name_to_data:
img = self._image_name_to_qimage(name)
if img:
name_to_data[name] = self._qimage_to_data_url(img, "PNG")
if cur.atEnd():
break
cur.clearSelection()
# 2) Serialize and replace names with data URLs
html = self.document().toHtml()
for old, data_url in name_to_data.items():
html = html.replace(f'src="{old}"', f'src="{data_url}"')
html = html.replace(f"src='{old}'", f"src='{data_url}'")
return html
def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
c = self.textCursor()
# Dont drop inside a code frame
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
# Start a fresh paragraph if mid-line
if c.positionInBlock() != 0:
c.insertBlock()
if autoscale and self.viewport():
max_w = int(self.viewport().width() * 0.92)
if img.width() > max_w:
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
c.insertImage(img)
c.insertBlock() # one blank line after the image
def _image_info_at_cursor(self):
"""
Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
"""
# Try current position (select 1 char forward)
tc = QTextCursor(self.textCursor())
tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
fmt = tc.charFormat()
if fmt.isImageFormat():
imgfmt = QTextImageFormat(fmt)
img = self._resolve_image_resource(imgfmt)
return tc, imgfmt, img
# Try previous char (if caret is just after the image)
tc = QTextCursor(self.textCursor())
if tc.position() > 0:
tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
fmt = tc.charFormat()
if fmt.isImageFormat():
imgfmt = QTextImageFormat(fmt)
img = self._resolve_image_resource(imgfmt)
return tc, imgfmt, img
return None, None, None
def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
"""
Fetch the original QImage backing the inline image, if available.
"""
name = imgfmt.name()
if name:
try:
img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
if isinstance(img, QImage) and not img.isNull():
return img
except Exception:
pass
return None # fallback handled by callers
def _apply_image_size(
self,
tc: QTextCursor,
imgfmt: QTextImageFormat,
new_w: float,
orig_img: QImage | None,
):
# compute height proportionally
if orig_img and orig_img.width() > 0:
ratio = new_w / orig_img.width()
new_h = max(1.0, orig_img.height() * ratio)
else:
# fallback: keep current aspect ratio if we have it
cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
ratio = new_w / max(1.0, cur_w)
new_h = max(1.0, cur_h * ratio)
imgfmt.setWidth(max(1.0, new_w))
imgfmt.setHeight(max(1.0, new_h))
tc.mergeCharFormat(imgfmt)
def _scale_image_at_cursor(self, factor: float):
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt:
return
base_w = imgfmt.width()
if base_w <= 0 and orig:
base_w = orig.width()
if base_w <= 0:
return
self._apply_image_size(tc, imgfmt, base_w * factor, orig)
def _fit_image_to_editor_width(self):
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt:
return
if not self.viewport():
return
target = int(self.viewport().width() * 0.92)
self._apply_image_size(tc, imgfmt, target, orig)
def _set_image_width_dialog(self):
from PySide6.QtWidgets import QInputDialog
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt:
return
# propose current display width or original width
cur_w = (
int(imgfmt.width())
if imgfmt.width() > 0
else (orig.width() if orig else 400)
)
w, ok = QInputDialog.getInt(
self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
)
if ok:
self._apply_image_size(tc, imgfmt, float(w), orig)
def _reset_image_size(self):
tc, imgfmt, orig = self._image_info_at_cursor()
if not imgfmt or not orig:
return
self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
def contextMenuEvent(self, e):
menu = self.createStandardContextMenu()
tc, imgfmt, orig = self._image_info_at_cursor()
if imgfmt:
menu.addSeparator()
sub = menu.addMenu("Image size")
sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
sub.addAction("Set width…", self._set_image_width_dialog)
sub.addAction("Reset to original", self._reset_image_size)
menu.exec(e.globalPos())
def insertFromMimeData(self, source):
# 1) Direct image from clipboard
if source.hasImage():
img = self._to_qimage(source.imageData())
if img is not None:
self._insert_qimage_at_cursor(self, img, autoscale=True)
return
# 2) File URLs (drag/drop or paste)
if source.hasUrls():
paths = []
non_local_urls = []
for url in source.urls():
if url.isLocalFile():
path = url.toLocalFile()
if path.lower().endswith(self._IMAGE_EXTS):
paths.append(path)
else:
# Non-image file: insert as link
self.textCursor().insertHtml(
f'<a href="{url.toString()}">{Path(path).name}</a>'
)
self.textCursor().insertBlock()
else:
non_local_urls.append(url)
if paths:
self.insert_images(paths)
for url in non_local_urls:
self.textCursor().insertHtml(
f'<a href="{url.toString()}">{url.toString()}</a>'
)
self.textCursor().insertBlock()
if paths or non_local_urls:
return
# 3) HTML with data: image
if source.hasHtml():
html = source.html()
m = self._DATA_IMG_RX.search(html or "")
if m:
try:
data = base64.b64decode(m.group(1))
img = QImage.fromData(data)
if not img.isNull():
self._insert_qimage_at_cursor(self, img, autoscale=True)
return
except Exception:
pass # fall through
# 4) Everything else → default behavior
super().insertFromMimeData(source)
@Slot(list)
def insert_images(self, paths: list[str], autoscale=True):
"""
Insert one or more images at the cursor. Large images can be auto-scaled
to fit the viewport width while preserving aspect ratio.
"""
c = self.textCursor()
# Avoid dropping images inside a code frame
frame = self._find_code_frame(c)
if frame:
out = QTextCursor(self.document())
out.setPosition(frame.lastPosition())
self.setTextCursor(out)
c = self.textCursor()
# Ensure there's a paragraph break if we're mid-line
if c.positionInBlock() != 0:
c.insertBlock()
for path in paths:
reader = QImageReader(path)
img = reader.read()
if img.isNull():
continue
if autoscale and self.viewport():
max_w = int(self.viewport().width() * 0.92) # ~92% of editor width
if img.width() > max_w:
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
c.insertImage(img)
c.insertBlock() # put each image on its own line
def mouseReleaseEvent(self, e): def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
href = self.anchorAt(e.pos()) href = self.anchorAt(e.pos())

View file

@ -105,8 +105,8 @@ class _LockOverlay(QWidget):
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self, *args, **kwargs):
super().__init__() super().__init__(*args, **kwargs)
self.setWindowTitle(APP_NAME) self.setWindowTitle(APP_NAME)
self.setMinimumSize(1000, 650) self.setMinimumSize(1000, 650)
@ -160,6 +160,7 @@ class MainWindow(QMainWindow):
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
self.toolBar.alignRequested.connect(self.editor.setAlignment) self.toolBar.alignRequested.connect(self.editor.setAlignment)
self.toolBar.historyRequested.connect(self._open_history) self.toolBar.historyRequested.connect(self._open_history)
self.toolBar.insertImageRequested.connect(self._on_insert_image)
self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar()) self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
self.editor.cursorPositionChanged.connect(self._sync_toolbar) self.editor.cursorPositionChanged.connect(self._sync_toolbar)
@ -446,7 +447,7 @@ class MainWindow(QMainWindow):
""" """
if not self._dirty and not explicit: if not self._dirty and not explicit:
return return
text = self.editor.toHtml() text = self.editor.to_html_with_embedded_images()
try: try:
self.db.save_new_version(date_iso, text, note) self.db.save_new_version(date_iso, text, note)
except Exception as e: except Exception as e:
@ -489,6 +490,18 @@ class MainWindow(QMainWindow):
self._load_selected_date(date_iso) self._load_selected_date(date_iso)
self._refresh_calendar_marks() self._refresh_calendar_marks()
def _on_insert_image(self):
# Let the user pick one or many images
paths, _ = QFileDialog.getOpenFileNames(
self,
"Insert image(s)",
"",
"Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
)
if not paths:
return
self.editor.insert_images(paths) # call into the editor
# ----------- Settings handler ------------# # ----------- Settings handler ------------#
def _open_settings(self): def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self) dlg = SettingsDialog(self.cfg, self.db, self)

View file

@ -16,6 +16,7 @@ class ToolBar(QToolBar):
numbersRequested = Signal() numbersRequested = Signal()
alignRequested = Signal(Qt.AlignmentFlag) alignRequested = Signal(Qt.AlignmentFlag)
historyRequested = Signal() historyRequested = Signal()
insertImageRequested = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__("Format", parent) super().__init__("Format", parent)
@ -86,6 +87,12 @@ class ToolBar(QToolBar):
self.actNumbers.setCheckable(True) self.actNumbers.setCheckable(True)
self.actNumbers.triggered.connect(self.numbersRequested) self.actNumbers.triggered.connect(self.numbersRequested)
# Images
self.actInsertImg = QAction("Image", self)
self.actInsertImg.setToolTip("Insert image")
self.actInsertImg.setShortcut("Ctrl+Shift+I")
self.actInsertImg.triggered.connect(self.insertImageRequested)
# Alignment # Alignment
self.actAlignL = QAction("L", self) self.actAlignL = QAction("L", self)
self.actAlignL.setToolTip("Align Left") self.actAlignL.setToolTip("Align Left")
@ -143,6 +150,7 @@ class ToolBar(QToolBar):
self.actNormal, self.actNormal,
self.actBullets, self.actBullets,
self.actNumbers, self.actNumbers,
self.actInsertImg,
self.actAlignL, self.actAlignL,
self.actAlignC, self.actAlignC,
self.actAlignR, self.actAlignR,