From 74a75eadcb3e9ef409d832beecf19d35dd045541 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 4 Nov 2025 15:57:41 +1100 Subject: [PATCH] Add ability to storage images in the page --- CHANGELOG.md | 1 + bouquin/db.py | 2 +- bouquin/editor.py | 306 ++++++++++++++++++++++++++++++++++++++++- bouquin/main_window.py | 19 ++- bouquin/toolbar.py | 8 ++ 5 files changed, 331 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f4397..e69fc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Add plaintext SQLite3 Export option * Add Backup option (database remains encrypted with SQLCipher) * Add ability to run VACUUM (compact) on the database in settings + * Add ability to store images in the page # 0.1.8 diff --git a/bouquin/db.py b/bouquin/db.py index f75fdbb..82b195f 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -470,7 +470,7 @@ class DBManager: """ try: cur = self.conn.cursor() - cur.execute(f"VACUUM") + cur.execute("VACUUM") except Exception as e: print(f"Error: {e}") diff --git a/bouquin/editor.py b/bouquin/editor.py index afcd7e4..07ef6d3 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -1,17 +1,34 @@ from __future__ import annotations +from pathlib import Path +import base64, re + from PySide6.QtGui import ( QColor, QDesktopServices, QFont, QFontDatabase, + QImage, + QImageReader, + QPixmap, QTextCharFormat, QTextCursor, QTextFrameFormat, QTextListFormat, 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 @@ -22,6 +39,8 @@ class Editor(QTextEdit): _CODE_BG = QColor(245, 245, 245) _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames _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): super().__init__(*args, **kwargs) @@ -129,6 +148,291 @@ class Editor(QTextEdit): finally: 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() + + # Don’t 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'{Path(path).name}' + ) + 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'{url.toString()}' + ) + 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): if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier): href = self.anchorAt(e.pos()) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index df1726d..cbcb312 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -105,8 +105,8 @@ class _LockOverlay(QWidget): class MainWindow(QMainWindow): - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.setWindowTitle(APP_NAME) self.setMinimumSize(1000, 650) @@ -160,6 +160,7 @@ class MainWindow(QMainWindow): self.toolBar.numbersRequested.connect(self.editor.toggle_numbers) self.toolBar.alignRequested.connect(self.editor.setAlignment) 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.cursorPositionChanged.connect(self._sync_toolbar) @@ -446,7 +447,7 @@ class MainWindow(QMainWindow): """ if not self._dirty and not explicit: return - text = self.editor.toHtml() + text = self.editor.to_html_with_embedded_images() try: self.db.save_new_version(date_iso, text, note) except Exception as e: @@ -489,6 +490,18 @@ class MainWindow(QMainWindow): self._load_selected_date(date_iso) 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 ------------# def _open_settings(self): dlg = SettingsDialog(self.cfg, self.db, self) diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index e9d2777..5d5c451 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -16,6 +16,7 @@ class ToolBar(QToolBar): numbersRequested = Signal() alignRequested = Signal(Qt.AlignmentFlag) historyRequested = Signal() + insertImageRequested = Signal() def __init__(self, parent=None): super().__init__("Format", parent) @@ -86,6 +87,12 @@ class ToolBar(QToolBar): self.actNumbers.setCheckable(True) 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 self.actAlignL = QAction("L", self) self.actAlignL.setToolTip("Align Left") @@ -143,6 +150,7 @@ class ToolBar(QToolBar): self.actNormal, self.actBullets, self.actNumbers, + self.actInsertImg, self.actAlignL, self.actAlignC, self.actAlignR,