bouquin 0.1.12__py3-none-any.whl → 0.2.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bouquin/db.py +4 -41
- bouquin/find_bar.py +33 -11
- bouquin/history_dialog.py +27 -26
- bouquin/main_window.py +495 -120
- bouquin/markdown_editor.py +920 -0
- bouquin/search.py +46 -30
- bouquin/toolbar.py +0 -42
- {bouquin-0.1.12.dist-info → bouquin-0.2.1.3.dist-info}/METADATA +5 -7
- bouquin-0.2.1.3.dist-info/RECORD +21 -0
- bouquin/editor.py +0 -1009
- bouquin-0.1.12.dist-info/RECORD +0 -21
- {bouquin-0.1.12.dist-info → bouquin-0.2.1.3.dist-info}/LICENSE +0 -0
- {bouquin-0.1.12.dist-info → bouquin-0.2.1.3.dist-info}/WHEEL +0 -0
- {bouquin-0.1.12.dist-info → bouquin-0.2.1.3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PySide6.QtGui import (
|
|
8
|
+
QColor,
|
|
9
|
+
QFont,
|
|
10
|
+
QFontDatabase,
|
|
11
|
+
QFontMetrics,
|
|
12
|
+
QImage,
|
|
13
|
+
QPalette,
|
|
14
|
+
QGuiApplication,
|
|
15
|
+
QTextCharFormat,
|
|
16
|
+
QTextCursor,
|
|
17
|
+
QTextDocument,
|
|
18
|
+
QSyntaxHighlighter,
|
|
19
|
+
QTextImageFormat,
|
|
20
|
+
)
|
|
21
|
+
from PySide6.QtCore import Qt, QRect
|
|
22
|
+
from PySide6.QtWidgets import QTextEdit
|
|
23
|
+
|
|
24
|
+
from .theme import ThemeManager, Theme
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MarkdownHighlighter(QSyntaxHighlighter):
|
|
28
|
+
"""Live syntax highlighter for markdown that applies formatting as you type."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
|
|
31
|
+
super().__init__(document)
|
|
32
|
+
self.theme_manager = theme_manager
|
|
33
|
+
self._setup_formats()
|
|
34
|
+
# Recompute formats whenever the app theme changes
|
|
35
|
+
try:
|
|
36
|
+
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
|
37
|
+
self.textChanged.connect(self._refresh_codeblock_margins)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def _on_theme_changed(self, *_):
|
|
42
|
+
self._setup_formats()
|
|
43
|
+
self.rehighlight()
|
|
44
|
+
|
|
45
|
+
def _setup_formats(self):
|
|
46
|
+
"""Setup text formats for different markdown elements."""
|
|
47
|
+
# Bold: **text** or __text__
|
|
48
|
+
self.bold_format = QTextCharFormat()
|
|
49
|
+
self.bold_format.setFontWeight(QFont.Weight.Bold)
|
|
50
|
+
|
|
51
|
+
# Italic: *text* or _text_
|
|
52
|
+
self.italic_format = QTextCharFormat()
|
|
53
|
+
self.italic_format.setFontItalic(True)
|
|
54
|
+
|
|
55
|
+
# Strikethrough: ~~text~~
|
|
56
|
+
self.strike_format = QTextCharFormat()
|
|
57
|
+
self.strike_format.setFontStrikeOut(True)
|
|
58
|
+
|
|
59
|
+
# Code: `code`
|
|
60
|
+
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
61
|
+
self.code_format = QTextCharFormat()
|
|
62
|
+
self.code_format.setFont(mono)
|
|
63
|
+
self.code_format.setFontFixedPitch(True)
|
|
64
|
+
|
|
65
|
+
# Code block: ```
|
|
66
|
+
self.code_block_format = QTextCharFormat()
|
|
67
|
+
self.code_block_format.setFont(mono)
|
|
68
|
+
self.code_block_format.setFontFixedPitch(True)
|
|
69
|
+
|
|
70
|
+
pal = QGuiApplication.palette()
|
|
71
|
+
if self.theme_manager.current() == Theme.DARK:
|
|
72
|
+
# In dark mode, use a darker panel-like background
|
|
73
|
+
bg = pal.color(QPalette.AlternateBase)
|
|
74
|
+
fg = pal.color(QPalette.Text)
|
|
75
|
+
else:
|
|
76
|
+
# Light mode: keep the existing light gray
|
|
77
|
+
bg = QColor(245, 245, 245)
|
|
78
|
+
fg = pal.color(QPalette.Text)
|
|
79
|
+
self.code_block_format.setBackground(bg)
|
|
80
|
+
self.code_block_format.setForeground(fg)
|
|
81
|
+
|
|
82
|
+
# Headings
|
|
83
|
+
self.h1_format = QTextCharFormat()
|
|
84
|
+
self.h1_format.setFontPointSize(24.0)
|
|
85
|
+
self.h1_format.setFontWeight(QFont.Weight.Bold)
|
|
86
|
+
|
|
87
|
+
self.h2_format = QTextCharFormat()
|
|
88
|
+
self.h2_format.setFontPointSize(18.0)
|
|
89
|
+
self.h2_format.setFontWeight(QFont.Weight.Bold)
|
|
90
|
+
|
|
91
|
+
self.h3_format = QTextCharFormat()
|
|
92
|
+
self.h3_format.setFontPointSize(14.0)
|
|
93
|
+
self.h3_format.setFontWeight(QFont.Weight.Bold)
|
|
94
|
+
|
|
95
|
+
# Markdown syntax (the markers themselves) - make invisible
|
|
96
|
+
self.syntax_format = QTextCharFormat()
|
|
97
|
+
# Make the markers invisible by setting font size to 0.1 points
|
|
98
|
+
self.syntax_format.setFontPointSize(0.1)
|
|
99
|
+
# Also make them very faint in case they still show
|
|
100
|
+
self.syntax_format.setForeground(QColor(250, 250, 250))
|
|
101
|
+
|
|
102
|
+
def _refresh_codeblock_margins(self):
|
|
103
|
+
"""Give code blocks a small left/right margin to separate them visually."""
|
|
104
|
+
doc = self.document()
|
|
105
|
+
block = doc.begin()
|
|
106
|
+
in_code = False
|
|
107
|
+
while block.isValid():
|
|
108
|
+
txt = block.text().strip()
|
|
109
|
+
cursor = QTextCursor(block)
|
|
110
|
+
fmt = block.blockFormat()
|
|
111
|
+
|
|
112
|
+
if txt.startswith("```"):
|
|
113
|
+
# fence lines: small vertical spacing, same left indent
|
|
114
|
+
need = (12, 6, 6) # left, top, bottom (px-like)
|
|
115
|
+
if (fmt.leftMargin(), fmt.topMargin(), fmt.bottomMargin()) != need:
|
|
116
|
+
fmt.setLeftMargin(12)
|
|
117
|
+
fmt.setRightMargin(6)
|
|
118
|
+
fmt.setTopMargin(6)
|
|
119
|
+
fmt.setBottomMargin(6)
|
|
120
|
+
cursor.setBlockFormat(fmt)
|
|
121
|
+
in_code = not in_code
|
|
122
|
+
|
|
123
|
+
elif in_code:
|
|
124
|
+
# inside the code block
|
|
125
|
+
if fmt.leftMargin() != 12 or fmt.rightMargin() != 6:
|
|
126
|
+
fmt.setLeftMargin(12)
|
|
127
|
+
fmt.setRightMargin(6)
|
|
128
|
+
cursor.setBlockFormat(fmt)
|
|
129
|
+
|
|
130
|
+
block = block.next()
|
|
131
|
+
|
|
132
|
+
def highlightBlock(self, text: str):
|
|
133
|
+
"""Apply formatting to a block of text based on markdown syntax."""
|
|
134
|
+
if not text:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# Track if we're in a code block (multiline)
|
|
138
|
+
prev_state = self.previousBlockState()
|
|
139
|
+
in_code_block = prev_state == 1
|
|
140
|
+
|
|
141
|
+
# Check for code block fences
|
|
142
|
+
if text.strip().startswith("```"):
|
|
143
|
+
# background for the whole fence line (so block looks continuous)
|
|
144
|
+
self.setFormat(0, len(text), self.code_block_format)
|
|
145
|
+
|
|
146
|
+
# hide the three backticks themselves
|
|
147
|
+
idx = text.find("```")
|
|
148
|
+
if idx != -1:
|
|
149
|
+
self.setFormat(idx, 3, self.syntax_format)
|
|
150
|
+
|
|
151
|
+
# toggle code-block state and stop; next line picks up state
|
|
152
|
+
in_code_block = not in_code_block
|
|
153
|
+
self.setCurrentBlockState(1 if in_code_block else 0)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if in_code_block:
|
|
157
|
+
# Format entire line as code
|
|
158
|
+
self.setFormat(0, len(text), self.code_block_format)
|
|
159
|
+
self.setCurrentBlockState(1)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
self.setCurrentBlockState(0)
|
|
163
|
+
|
|
164
|
+
# Headings (must be at start of line)
|
|
165
|
+
heading_match = re.match(r"^(#{1,3})\s+", text)
|
|
166
|
+
if heading_match:
|
|
167
|
+
level = len(heading_match.group(1))
|
|
168
|
+
marker_len = len(heading_match.group(0))
|
|
169
|
+
|
|
170
|
+
# Format the # markers
|
|
171
|
+
self.setFormat(0, marker_len, self.syntax_format)
|
|
172
|
+
|
|
173
|
+
# Format the heading text
|
|
174
|
+
heading_fmt = (
|
|
175
|
+
self.h1_format
|
|
176
|
+
if level == 1
|
|
177
|
+
else self.h2_format if level == 2 else self.h3_format
|
|
178
|
+
)
|
|
179
|
+
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Bold: **text** or __text__
|
|
183
|
+
for match in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text):
|
|
184
|
+
start, end = match.span()
|
|
185
|
+
content_start = start + 2
|
|
186
|
+
content_end = end - 2
|
|
187
|
+
|
|
188
|
+
# Gray out the markers
|
|
189
|
+
self.setFormat(start, 2, self.syntax_format)
|
|
190
|
+
self.setFormat(end - 2, 2, self.syntax_format)
|
|
191
|
+
|
|
192
|
+
# Bold the content
|
|
193
|
+
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
|
194
|
+
|
|
195
|
+
# Italic: *text* or _text_ (but not part of bold)
|
|
196
|
+
for match in re.finditer(
|
|
197
|
+
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
|
198
|
+
):
|
|
199
|
+
start, end = match.span()
|
|
200
|
+
# Skip if this is part of a bold pattern
|
|
201
|
+
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
|
|
202
|
+
continue
|
|
203
|
+
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
content_start = start + 1
|
|
207
|
+
content_end = end - 1
|
|
208
|
+
|
|
209
|
+
# Gray out markers
|
|
210
|
+
self.setFormat(start, 1, self.syntax_format)
|
|
211
|
+
self.setFormat(end - 1, 1, self.syntax_format)
|
|
212
|
+
|
|
213
|
+
# Italicize content
|
|
214
|
+
self.setFormat(
|
|
215
|
+
content_start, content_end - content_start, self.italic_format
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Strikethrough: ~~text~~
|
|
219
|
+
for match in re.finditer(r"~~(.+?)~~", text):
|
|
220
|
+
start, end = match.span()
|
|
221
|
+
content_start = start + 2
|
|
222
|
+
content_end = end - 2
|
|
223
|
+
|
|
224
|
+
self.setFormat(start, 2, self.syntax_format)
|
|
225
|
+
self.setFormat(end - 2, 2, self.syntax_format)
|
|
226
|
+
self.setFormat(
|
|
227
|
+
content_start, content_end - content_start, self.strike_format
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Inline code: `code`
|
|
231
|
+
for match in re.finditer(r"`([^`]+)`", text):
|
|
232
|
+
start, end = match.span()
|
|
233
|
+
content_start = start + 1
|
|
234
|
+
content_end = end - 1
|
|
235
|
+
|
|
236
|
+
self.setFormat(start, 1, self.syntax_format)
|
|
237
|
+
self.setFormat(end - 1, 1, self.syntax_format)
|
|
238
|
+
self.setFormat(content_start, content_end - content_start, self.code_format)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class MarkdownEditor(QTextEdit):
|
|
242
|
+
"""A QTextEdit that stores/loads markdown and provides live rendering."""
|
|
243
|
+
|
|
244
|
+
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
|
245
|
+
|
|
246
|
+
# Checkbox characters (Unicode for display, markdown for storage)
|
|
247
|
+
_CHECK_UNCHECKED_DISPLAY = "☐"
|
|
248
|
+
_CHECK_CHECKED_DISPLAY = "☑"
|
|
249
|
+
_CHECK_UNCHECKED_STORAGE = "[ ]"
|
|
250
|
+
_CHECK_CHECKED_STORAGE = "[x]"
|
|
251
|
+
|
|
252
|
+
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
|
253
|
+
super().__init__(*args, **kwargs)
|
|
254
|
+
|
|
255
|
+
self.theme_manager = theme_manager
|
|
256
|
+
|
|
257
|
+
# Setup tab width
|
|
258
|
+
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
|
259
|
+
self.setTabStopDistance(tab_w)
|
|
260
|
+
|
|
261
|
+
# We accept plain text, not rich text (markdown is plain text)
|
|
262
|
+
self.setAcceptRichText(False)
|
|
263
|
+
|
|
264
|
+
# Install syntax highlighter
|
|
265
|
+
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
|
|
266
|
+
|
|
267
|
+
# Track current list type for smart enter handling
|
|
268
|
+
self._last_enter_was_empty = False
|
|
269
|
+
|
|
270
|
+
# Track if we're currently updating text programmatically
|
|
271
|
+
self._updating = False
|
|
272
|
+
|
|
273
|
+
# Connect to text changes for smart formatting
|
|
274
|
+
self.textChanged.connect(self._on_text_changed)
|
|
275
|
+
|
|
276
|
+
# Enable mouse tracking for checkbox clicking
|
|
277
|
+
self.viewport().setMouseTracking(True)
|
|
278
|
+
|
|
279
|
+
def setDocument(self, doc):
|
|
280
|
+
super().setDocument(doc)
|
|
281
|
+
# reattach the highlighter to the new document
|
|
282
|
+
if hasattr(self, "highlighter") and self.highlighter:
|
|
283
|
+
self.highlighter.setDocument(self.document())
|
|
284
|
+
|
|
285
|
+
def _on_text_changed(self):
|
|
286
|
+
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
|
287
|
+
if self._updating:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
self._updating = True
|
|
291
|
+
try:
|
|
292
|
+
c = self.textCursor()
|
|
293
|
+
block = c.block()
|
|
294
|
+
line = block.text()
|
|
295
|
+
pos_in_block = c.position() - block.position()
|
|
296
|
+
|
|
297
|
+
# Transform only this line:
|
|
298
|
+
# - "TODO " at start (with optional indent) -> "- ☐ "
|
|
299
|
+
# - "- [ ] " -> " ☐ " and "- [x] " -> " ☑ "
|
|
300
|
+
def transform_line(s: str) -> str:
|
|
301
|
+
s = s.replace("- [x] ", f"{self._CHECK_CHECKED_DISPLAY} ")
|
|
302
|
+
s = s.replace("- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
|
303
|
+
s = re.sub(
|
|
304
|
+
r"^([ \t]*)TODO\b[:\-]?\s+",
|
|
305
|
+
lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
|
|
306
|
+
s,
|
|
307
|
+
)
|
|
308
|
+
return s
|
|
309
|
+
|
|
310
|
+
new_line = transform_line(line)
|
|
311
|
+
if new_line != line:
|
|
312
|
+
# Replace just the current block
|
|
313
|
+
bc = QTextCursor(block)
|
|
314
|
+
bc.beginEditBlock()
|
|
315
|
+
bc.select(QTextCursor.BlockUnderCursor)
|
|
316
|
+
bc.insertText(new_line)
|
|
317
|
+
bc.endEditBlock()
|
|
318
|
+
|
|
319
|
+
# Restore cursor near its original visual position in the edited line
|
|
320
|
+
new_pos = min(
|
|
321
|
+
block.position() + len(new_line), block.position() + pos_in_block
|
|
322
|
+
)
|
|
323
|
+
c.setPosition(new_pos)
|
|
324
|
+
self.setTextCursor(c)
|
|
325
|
+
finally:
|
|
326
|
+
self._updating = False
|
|
327
|
+
|
|
328
|
+
def to_markdown(self) -> str:
|
|
329
|
+
"""Export current content as markdown (convert Unicode checkboxes back to markdown)."""
|
|
330
|
+
# First, extract any embedded images and convert to markdown
|
|
331
|
+
text = self._extract_images_to_markdown()
|
|
332
|
+
|
|
333
|
+
# Convert Unicode checkboxes back to markdown syntax
|
|
334
|
+
text = text.replace(f"{self._CHECK_CHECKED_DISPLAY} ", "- [x] ")
|
|
335
|
+
text = text.replace(f"{self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ")
|
|
336
|
+
|
|
337
|
+
return text
|
|
338
|
+
|
|
339
|
+
def _extract_images_to_markdown(self) -> str:
|
|
340
|
+
"""Extract embedded images and convert them back to markdown format."""
|
|
341
|
+
doc = self.document()
|
|
342
|
+
cursor = QTextCursor(doc)
|
|
343
|
+
|
|
344
|
+
# Build the output text with images as markdown
|
|
345
|
+
result = []
|
|
346
|
+
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
|
347
|
+
|
|
348
|
+
block = doc.begin()
|
|
349
|
+
while block.isValid():
|
|
350
|
+
it = block.begin()
|
|
351
|
+
block_text = ""
|
|
352
|
+
|
|
353
|
+
while not it.atEnd():
|
|
354
|
+
fragment = it.fragment()
|
|
355
|
+
if fragment.isValid():
|
|
356
|
+
if fragment.charFormat().isImageFormat():
|
|
357
|
+
# This is an image - convert to markdown
|
|
358
|
+
img_format = fragment.charFormat().toImageFormat()
|
|
359
|
+
img_name = img_format.name()
|
|
360
|
+
# The name contains the data URI
|
|
361
|
+
if img_name.startswith("data:image/"):
|
|
362
|
+
block_text += f""
|
|
363
|
+
else:
|
|
364
|
+
# Regular text
|
|
365
|
+
block_text += fragment.text()
|
|
366
|
+
it += 1
|
|
367
|
+
|
|
368
|
+
result.append(block_text)
|
|
369
|
+
block = block.next()
|
|
370
|
+
|
|
371
|
+
return "\n".join(result)
|
|
372
|
+
|
|
373
|
+
def from_markdown(self, markdown_text: str):
|
|
374
|
+
"""Load markdown text into the editor (convert markdown checkboxes to Unicode)."""
|
|
375
|
+
# Convert markdown checkboxes to Unicode for display
|
|
376
|
+
display_text = markdown_text.replace(
|
|
377
|
+
"- [x] ", f"{self._CHECK_CHECKED_DISPLAY} "
|
|
378
|
+
)
|
|
379
|
+
display_text = display_text.replace(
|
|
380
|
+
"- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} "
|
|
381
|
+
)
|
|
382
|
+
# Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
|
|
383
|
+
display_text = re.sub(
|
|
384
|
+
r"(?m)^([ \t]*)TODO\s",
|
|
385
|
+
lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
|
|
386
|
+
display_text,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
self._updating = True
|
|
390
|
+
try:
|
|
391
|
+
self.setPlainText(display_text)
|
|
392
|
+
if hasattr(self, "highlighter") and self.highlighter:
|
|
393
|
+
self.highlighter.rehighlight()
|
|
394
|
+
finally:
|
|
395
|
+
self._updating = False
|
|
396
|
+
|
|
397
|
+
# Render any embedded images
|
|
398
|
+
self._render_images()
|
|
399
|
+
|
|
400
|
+
def _render_images(self):
|
|
401
|
+
"""Find and render base64 images in the document."""
|
|
402
|
+
text = self.toPlainText()
|
|
403
|
+
|
|
404
|
+
# Pattern for markdown images with base64 data
|
|
405
|
+
img_pattern = r"!\[([^\]]*)\]\(data:image/([^;]+);base64,([^\)]+)\)"
|
|
406
|
+
|
|
407
|
+
matches = list(re.finditer(img_pattern, text))
|
|
408
|
+
|
|
409
|
+
if not matches:
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
# Process matches in reverse to preserve positions
|
|
413
|
+
for match in reversed(matches):
|
|
414
|
+
mime_type = match.group(2)
|
|
415
|
+
b64_data = match.group(3)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
# Decode base64 to image
|
|
419
|
+
img_bytes = base64.b64decode(b64_data)
|
|
420
|
+
image = QImage.fromData(img_bytes)
|
|
421
|
+
|
|
422
|
+
if image.isNull():
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
# Use original image size - no scaling
|
|
426
|
+
original_width = image.width()
|
|
427
|
+
original_height = image.height()
|
|
428
|
+
|
|
429
|
+
# Create image format with original base64
|
|
430
|
+
img_format = QTextImageFormat()
|
|
431
|
+
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
|
432
|
+
img_format.setWidth(original_width)
|
|
433
|
+
img_format.setHeight(original_height)
|
|
434
|
+
|
|
435
|
+
# Add image to document resources
|
|
436
|
+
self.document().addResource(
|
|
437
|
+
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Replace markdown with rendered image
|
|
441
|
+
cursor = QTextCursor(self.document())
|
|
442
|
+
cursor.setPosition(match.start())
|
|
443
|
+
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
|
|
444
|
+
cursor.insertImage(img_format)
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
# If image fails to render, leave the markdown as-is
|
|
448
|
+
print(f"Failed to render image: {e}")
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
def _get_current_line(self) -> str:
|
|
452
|
+
"""Get the text of the current line."""
|
|
453
|
+
cursor = self.textCursor()
|
|
454
|
+
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
|
455
|
+
return cursor.selectedText()
|
|
456
|
+
|
|
457
|
+
def _detect_list_type(self, line: str) -> tuple[str | None, str]:
|
|
458
|
+
"""
|
|
459
|
+
Detect if line is a list item. Returns (list_type, prefix).
|
|
460
|
+
list_type: 'bullet', 'number', 'checkbox', or None
|
|
461
|
+
prefix: the actual prefix string to use (e.g., '- ', '1. ', '- ☐ ')
|
|
462
|
+
"""
|
|
463
|
+
line = line.lstrip()
|
|
464
|
+
|
|
465
|
+
# Checkbox list (Unicode display format)
|
|
466
|
+
if line.startswith(f"{self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith(
|
|
467
|
+
f"{self._CHECK_CHECKED_DISPLAY} "
|
|
468
|
+
):
|
|
469
|
+
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
|
470
|
+
|
|
471
|
+
# Bullet list
|
|
472
|
+
if re.match(r"^[-*+]\s", line):
|
|
473
|
+
match = re.match(r"^([-*+]\s)", line)
|
|
474
|
+
return ("bullet", match.group(1))
|
|
475
|
+
|
|
476
|
+
# Numbered list
|
|
477
|
+
if re.match(r"^\d+\.\s", line):
|
|
478
|
+
# Extract the number and increment
|
|
479
|
+
match = re.match(r"^(\d+)\.\s", line)
|
|
480
|
+
num = int(match.group(1))
|
|
481
|
+
return ("number", f"{num + 1}. ")
|
|
482
|
+
|
|
483
|
+
return (None, "")
|
|
484
|
+
|
|
485
|
+
def keyPressEvent(self, event):
|
|
486
|
+
"""Handle special key events for markdown editing."""
|
|
487
|
+
|
|
488
|
+
# --- Auto-close code fences when typing the 3rd backtick at line start ---
|
|
489
|
+
if event.text() == "`":
|
|
490
|
+
c = self.textCursor()
|
|
491
|
+
block = c.block()
|
|
492
|
+
line = block.text()
|
|
493
|
+
pos_in_block = c.position() - block.position()
|
|
494
|
+
|
|
495
|
+
# text before caret on this line
|
|
496
|
+
before = line[:pos_in_block]
|
|
497
|
+
|
|
498
|
+
# If we've typed exactly two backticks at line start (or after whitespace),
|
|
499
|
+
# treat this backtick as the "third" and expand to a full fenced block.
|
|
500
|
+
if before.endswith("``") and before.strip() == "``":
|
|
501
|
+
start = (
|
|
502
|
+
block.position() + pos_in_block - 2
|
|
503
|
+
) # start of the two backticks
|
|
504
|
+
|
|
505
|
+
edit = QTextCursor(self.document())
|
|
506
|
+
edit.beginEditBlock()
|
|
507
|
+
edit.setPosition(start)
|
|
508
|
+
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
|
509
|
+
edit.insertText("```\n\n```")
|
|
510
|
+
edit.endEditBlock()
|
|
511
|
+
|
|
512
|
+
# place caret on the blank line between the fences
|
|
513
|
+
new_pos = start + 4 # after "```\n"
|
|
514
|
+
c.setPosition(new_pos)
|
|
515
|
+
self.setTextCursor(c)
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# Handle Enter key for smart list continuation AND code blocks
|
|
519
|
+
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
|
520
|
+
cursor = self.textCursor()
|
|
521
|
+
current_line = self._get_current_line()
|
|
522
|
+
|
|
523
|
+
# Check if we're in a code block
|
|
524
|
+
current_block = cursor.block()
|
|
525
|
+
line_text = current_block.text()
|
|
526
|
+
pos_in_block = cursor.position() - current_block.position()
|
|
527
|
+
|
|
528
|
+
moved = False
|
|
529
|
+
i = 0
|
|
530
|
+
patterns = ["**", "__", "~~", "`", "*", "_"] # bold, italic, strike, code
|
|
531
|
+
# Consume stacked markers like **` if present
|
|
532
|
+
while True:
|
|
533
|
+
matched = False
|
|
534
|
+
for pat in patterns:
|
|
535
|
+
L = len(pat)
|
|
536
|
+
if line_text[pos_in_block + i : pos_in_block + i + L] == pat:
|
|
537
|
+
i += L
|
|
538
|
+
matched = True
|
|
539
|
+
moved = True
|
|
540
|
+
break
|
|
541
|
+
if not matched:
|
|
542
|
+
break
|
|
543
|
+
if moved:
|
|
544
|
+
cursor.movePosition(
|
|
545
|
+
QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, i
|
|
546
|
+
)
|
|
547
|
+
self.setTextCursor(cursor)
|
|
548
|
+
|
|
549
|
+
block_state = current_block.userState()
|
|
550
|
+
|
|
551
|
+
# If current line is opening code fence, or we're inside a code block
|
|
552
|
+
if current_line.strip().startswith("```") or block_state == 1:
|
|
553
|
+
# Just insert a regular newline - the highlighter will format it as code
|
|
554
|
+
super().keyPressEvent(event)
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
# Check for list continuation
|
|
558
|
+
list_type, prefix = self._detect_list_type(current_line)
|
|
559
|
+
|
|
560
|
+
if list_type:
|
|
561
|
+
# Check if the line is empty (just the prefix)
|
|
562
|
+
content = current_line.lstrip()
|
|
563
|
+
is_empty = (
|
|
564
|
+
content == prefix.strip() or not content.replace(prefix, "").strip()
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
if is_empty and self._last_enter_was_empty:
|
|
568
|
+
# Second enter on empty list item - remove the list formatting
|
|
569
|
+
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
|
570
|
+
cursor.removeSelectedText()
|
|
571
|
+
cursor.insertText("\n")
|
|
572
|
+
self._last_enter_was_empty = False
|
|
573
|
+
return
|
|
574
|
+
elif is_empty:
|
|
575
|
+
# First enter on empty list item - remember this
|
|
576
|
+
self._last_enter_was_empty = True
|
|
577
|
+
else:
|
|
578
|
+
# Not empty - continue the list
|
|
579
|
+
self._last_enter_was_empty = False
|
|
580
|
+
|
|
581
|
+
# Insert newline and continue the list
|
|
582
|
+
super().keyPressEvent(event)
|
|
583
|
+
cursor = self.textCursor()
|
|
584
|
+
cursor.insertText(prefix)
|
|
585
|
+
return
|
|
586
|
+
else:
|
|
587
|
+
self._last_enter_was_empty = False
|
|
588
|
+
else:
|
|
589
|
+
# Any other key resets the empty enter flag
|
|
590
|
+
self._last_enter_was_empty = False
|
|
591
|
+
|
|
592
|
+
# Default handling
|
|
593
|
+
super().keyPressEvent(event)
|
|
594
|
+
|
|
595
|
+
def mousePressEvent(self, event):
|
|
596
|
+
"""Toggle a checkbox only when the click lands on its icon."""
|
|
597
|
+
if event.button() == Qt.LeftButton:
|
|
598
|
+
pt = event.pos()
|
|
599
|
+
|
|
600
|
+
# Cursor and block under the mouse
|
|
601
|
+
cur = self.cursorForPosition(pt)
|
|
602
|
+
block = cur.block()
|
|
603
|
+
text = block.text()
|
|
604
|
+
|
|
605
|
+
# The display tokens, e.g. "☐ " / "☑ " (icon + trailing space)
|
|
606
|
+
unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
|
|
607
|
+
checked = f"{self._CHECK_CHECKED_DISPLAY} "
|
|
608
|
+
|
|
609
|
+
# Helper: rect for a single character at a given doc position
|
|
610
|
+
def char_rect_at(doc_pos, ch):
|
|
611
|
+
c = QTextCursor(self.document())
|
|
612
|
+
c.setPosition(doc_pos)
|
|
613
|
+
start_rect = self.cursorRect(
|
|
614
|
+
c
|
|
615
|
+
) # caret rect at char start (viewport coords)
|
|
616
|
+
|
|
617
|
+
# Use the actual font at this position for an accurate width
|
|
618
|
+
fmt_font = (
|
|
619
|
+
c.charFormat().font() if c.charFormat().isValid() else self.font()
|
|
620
|
+
)
|
|
621
|
+
fm = QFontMetrics(fmt_font)
|
|
622
|
+
w = max(1, fm.horizontalAdvance(ch))
|
|
623
|
+
return QRect(start_rect.x(), start_rect.y(), w, start_rect.height())
|
|
624
|
+
|
|
625
|
+
# Scan the line for any checkbox icons; toggle the one we clicked
|
|
626
|
+
i = 0
|
|
627
|
+
while i < len(text):
|
|
628
|
+
icon = None
|
|
629
|
+
if text.startswith(unchecked, i):
|
|
630
|
+
icon = self._CHECK_UNCHECKED_DISPLAY
|
|
631
|
+
elif text.startswith(checked, i):
|
|
632
|
+
icon = self._CHECK_CHECKED_DISPLAY
|
|
633
|
+
|
|
634
|
+
if icon:
|
|
635
|
+
doc_pos = (
|
|
636
|
+
block.position() + i
|
|
637
|
+
) # absolute document position of the icon
|
|
638
|
+
r = char_rect_at(doc_pos, icon)
|
|
639
|
+
|
|
640
|
+
if r.contains(pt):
|
|
641
|
+
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
|
|
642
|
+
new_icon = (
|
|
643
|
+
self._CHECK_CHECKED_DISPLAY
|
|
644
|
+
if icon == self._CHECK_UNCHECKED_DISPLAY
|
|
645
|
+
else self._CHECK_UNCHECKED_DISPLAY
|
|
646
|
+
)
|
|
647
|
+
edit = QTextCursor(self.document())
|
|
648
|
+
edit.beginEditBlock()
|
|
649
|
+
edit.setPosition(doc_pos)
|
|
650
|
+
edit.movePosition(
|
|
651
|
+
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
|
|
652
|
+
) # icon + space
|
|
653
|
+
edit.insertText(f"{new_icon} ")
|
|
654
|
+
edit.endEditBlock()
|
|
655
|
+
return # handled
|
|
656
|
+
|
|
657
|
+
# advance past this token
|
|
658
|
+
i += len(icon) + 1
|
|
659
|
+
else:
|
|
660
|
+
i += 1
|
|
661
|
+
|
|
662
|
+
# Default handling for anything else
|
|
663
|
+
super().mousePressEvent(event)
|
|
664
|
+
|
|
665
|
+
# ------------------------ Toolbar action handlers ------------------------
|
|
666
|
+
|
|
667
|
+
def apply_weight(self):
|
|
668
|
+
"""Toggle bold formatting."""
|
|
669
|
+
cursor = self.textCursor()
|
|
670
|
+
if cursor.hasSelection():
|
|
671
|
+
selected = cursor.selectedText()
|
|
672
|
+
# Check if already bold
|
|
673
|
+
if selected.startswith("**") and selected.endswith("**"):
|
|
674
|
+
# Remove bold
|
|
675
|
+
new_text = selected[2:-2]
|
|
676
|
+
else:
|
|
677
|
+
# Add bold
|
|
678
|
+
new_text = f"**{selected}**"
|
|
679
|
+
cursor.insertText(new_text)
|
|
680
|
+
else:
|
|
681
|
+
# No selection - just insert markers
|
|
682
|
+
cursor.insertText("****")
|
|
683
|
+
cursor.movePosition(
|
|
684
|
+
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
|
|
685
|
+
)
|
|
686
|
+
self.setTextCursor(cursor)
|
|
687
|
+
|
|
688
|
+
# Return focus to editor
|
|
689
|
+
self.setFocus()
|
|
690
|
+
|
|
691
|
+
def apply_italic(self):
|
|
692
|
+
"""Toggle italic formatting."""
|
|
693
|
+
cursor = self.textCursor()
|
|
694
|
+
if cursor.hasSelection():
|
|
695
|
+
selected = cursor.selectedText()
|
|
696
|
+
if (
|
|
697
|
+
selected.startswith("*")
|
|
698
|
+
and selected.endswith("*")
|
|
699
|
+
and not selected.startswith("**")
|
|
700
|
+
):
|
|
701
|
+
new_text = selected[1:-1]
|
|
702
|
+
else:
|
|
703
|
+
new_text = f"*{selected}*"
|
|
704
|
+
cursor.insertText(new_text)
|
|
705
|
+
else:
|
|
706
|
+
cursor.insertText("**")
|
|
707
|
+
cursor.movePosition(
|
|
708
|
+
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1
|
|
709
|
+
)
|
|
710
|
+
self.setTextCursor(cursor)
|
|
711
|
+
|
|
712
|
+
# Return focus to editor
|
|
713
|
+
self.setFocus()
|
|
714
|
+
|
|
715
|
+
def apply_strikethrough(self):
|
|
716
|
+
"""Toggle strikethrough formatting."""
|
|
717
|
+
cursor = self.textCursor()
|
|
718
|
+
if cursor.hasSelection():
|
|
719
|
+
selected = cursor.selectedText()
|
|
720
|
+
if selected.startswith("~~") and selected.endswith("~~"):
|
|
721
|
+
new_text = selected[2:-2]
|
|
722
|
+
else:
|
|
723
|
+
new_text = f"~~{selected}~~"
|
|
724
|
+
cursor.insertText(new_text)
|
|
725
|
+
else:
|
|
726
|
+
cursor.insertText("~~~~")
|
|
727
|
+
cursor.movePosition(
|
|
728
|
+
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
|
|
729
|
+
)
|
|
730
|
+
self.setTextCursor(cursor)
|
|
731
|
+
|
|
732
|
+
# Return focus to editor
|
|
733
|
+
self.setFocus()
|
|
734
|
+
|
|
735
|
+
def apply_code(self):
|
|
736
|
+
"""Insert or toggle code block."""
|
|
737
|
+
cursor = self.textCursor()
|
|
738
|
+
|
|
739
|
+
if cursor.hasSelection():
|
|
740
|
+
# Wrap selection in code fence
|
|
741
|
+
selected = cursor.selectedText()
|
|
742
|
+
# Note: selectedText() uses Unicode paragraph separator, replace with newline
|
|
743
|
+
selected = selected.replace("\u2029", "\n")
|
|
744
|
+
new_text = f"```\n{selected}\n```"
|
|
745
|
+
cursor.insertText(new_text)
|
|
746
|
+
else:
|
|
747
|
+
# Insert code block template
|
|
748
|
+
cursor.insertText("```\n\n```")
|
|
749
|
+
cursor.movePosition(
|
|
750
|
+
QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
|
|
751
|
+
)
|
|
752
|
+
self.setTextCursor(cursor)
|
|
753
|
+
|
|
754
|
+
# Return focus to editor
|
|
755
|
+
self.setFocus()
|
|
756
|
+
|
|
757
|
+
def apply_heading(self, size: int):
|
|
758
|
+
"""Apply heading formatting to current line."""
|
|
759
|
+
cursor = self.textCursor()
|
|
760
|
+
|
|
761
|
+
# Determine heading level from size
|
|
762
|
+
if size >= 24:
|
|
763
|
+
level = 1
|
|
764
|
+
elif size >= 18:
|
|
765
|
+
level = 2
|
|
766
|
+
elif size >= 14:
|
|
767
|
+
level = 3
|
|
768
|
+
else:
|
|
769
|
+
level = 0 # Normal text
|
|
770
|
+
|
|
771
|
+
# Get current line
|
|
772
|
+
cursor.movePosition(
|
|
773
|
+
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
|
774
|
+
)
|
|
775
|
+
cursor.movePosition(
|
|
776
|
+
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
|
777
|
+
)
|
|
778
|
+
line = cursor.selectedText()
|
|
779
|
+
|
|
780
|
+
# Remove existing heading markers
|
|
781
|
+
line = re.sub(r"^#{1,6}\s+", "", line)
|
|
782
|
+
|
|
783
|
+
# Add new heading markers if not normal
|
|
784
|
+
if level > 0:
|
|
785
|
+
new_line = "#" * level + " " + line
|
|
786
|
+
else:
|
|
787
|
+
new_line = line
|
|
788
|
+
|
|
789
|
+
cursor.insertText(new_line)
|
|
790
|
+
|
|
791
|
+
# Return focus to editor
|
|
792
|
+
self.setFocus()
|
|
793
|
+
|
|
794
|
+
def toggle_bullets(self):
|
|
795
|
+
"""Toggle bullet list on current line."""
|
|
796
|
+
cursor = self.textCursor()
|
|
797
|
+
cursor.movePosition(
|
|
798
|
+
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
|
799
|
+
)
|
|
800
|
+
cursor.movePosition(
|
|
801
|
+
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
|
802
|
+
)
|
|
803
|
+
line = cursor.selectedText()
|
|
804
|
+
|
|
805
|
+
# Check if already a bullet
|
|
806
|
+
if line.lstrip().startswith("- ") or line.lstrip().startswith("* "):
|
|
807
|
+
# Remove bullet
|
|
808
|
+
new_line = re.sub(r"^\s*[-*]\s+", "", line)
|
|
809
|
+
else:
|
|
810
|
+
# Add bullet
|
|
811
|
+
new_line = "- " + line.lstrip()
|
|
812
|
+
|
|
813
|
+
cursor.insertText(new_line)
|
|
814
|
+
|
|
815
|
+
# Return focus to editor
|
|
816
|
+
self.setFocus()
|
|
817
|
+
|
|
818
|
+
def toggle_numbers(self):
|
|
819
|
+
"""Toggle numbered list on current line."""
|
|
820
|
+
cursor = self.textCursor()
|
|
821
|
+
cursor.movePosition(
|
|
822
|
+
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
|
823
|
+
)
|
|
824
|
+
cursor.movePosition(
|
|
825
|
+
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
|
826
|
+
)
|
|
827
|
+
line = cursor.selectedText()
|
|
828
|
+
|
|
829
|
+
# Check if already numbered
|
|
830
|
+
if re.match(r"^\s*\d+\.\s", line):
|
|
831
|
+
# Remove number
|
|
832
|
+
new_line = re.sub(r"^\s*\d+\.\s+", "", line)
|
|
833
|
+
else:
|
|
834
|
+
# Add number
|
|
835
|
+
new_line = "1. " + line.lstrip()
|
|
836
|
+
|
|
837
|
+
cursor.insertText(new_line)
|
|
838
|
+
|
|
839
|
+
# Return focus to editor
|
|
840
|
+
self.setFocus()
|
|
841
|
+
|
|
842
|
+
def toggle_checkboxes(self):
|
|
843
|
+
"""Toggle checkbox on current line."""
|
|
844
|
+
cursor = self.textCursor()
|
|
845
|
+
cursor.movePosition(
|
|
846
|
+
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
|
847
|
+
)
|
|
848
|
+
cursor.movePosition(
|
|
849
|
+
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
|
850
|
+
)
|
|
851
|
+
line = cursor.selectedText()
|
|
852
|
+
|
|
853
|
+
# Check if already has checkbox (Unicode display format)
|
|
854
|
+
if (
|
|
855
|
+
f"{self._CHECK_UNCHECKED_DISPLAY} " in line
|
|
856
|
+
or f"{self._CHECK_CHECKED_DISPLAY} " in line
|
|
857
|
+
):
|
|
858
|
+
# Remove checkbox - use raw string to avoid escape sequence warning
|
|
859
|
+
new_line = re.sub(
|
|
860
|
+
rf"^\s*[{self._CHECK_UNCHECKED_DISPLAY}{self._CHECK_CHECKED_DISPLAY}]\s+",
|
|
861
|
+
"",
|
|
862
|
+
line,
|
|
863
|
+
)
|
|
864
|
+
else:
|
|
865
|
+
# Add checkbox (Unicode display format)
|
|
866
|
+
new_line = f"{self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip()
|
|
867
|
+
|
|
868
|
+
cursor.insertText(new_line)
|
|
869
|
+
|
|
870
|
+
# Return focus to editor
|
|
871
|
+
self.setFocus()
|
|
872
|
+
|
|
873
|
+
def insert_image_from_path(self, path: Path):
|
|
874
|
+
"""Insert an image as rendered image (but save as base64 markdown)."""
|
|
875
|
+
if not path.exists():
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
# Read the ORIGINAL image file bytes for base64 encoding
|
|
879
|
+
with open(path, "rb") as f:
|
|
880
|
+
img_data = f.read()
|
|
881
|
+
|
|
882
|
+
# Encode ORIGINAL file bytes to base64
|
|
883
|
+
b64_data = base64.b64encode(img_data).decode("ascii")
|
|
884
|
+
|
|
885
|
+
# Determine mime type
|
|
886
|
+
ext = path.suffix.lower()
|
|
887
|
+
mime_map = {
|
|
888
|
+
".png": "image/png",
|
|
889
|
+
".jpg": "image/jpeg",
|
|
890
|
+
".jpeg": "image/jpeg",
|
|
891
|
+
".gif": "image/gif",
|
|
892
|
+
".bmp": "image/bmp",
|
|
893
|
+
".webp": "image/webp",
|
|
894
|
+
}
|
|
895
|
+
mime_type = mime_map.get(ext, "image/png")
|
|
896
|
+
|
|
897
|
+
# Load the image
|
|
898
|
+
image = QImage(str(path))
|
|
899
|
+
if image.isNull():
|
|
900
|
+
return
|
|
901
|
+
|
|
902
|
+
# Use ORIGINAL size - no scaling!
|
|
903
|
+
original_width = image.width()
|
|
904
|
+
original_height = image.height()
|
|
905
|
+
|
|
906
|
+
# Create image format with original base64
|
|
907
|
+
img_format = QTextImageFormat()
|
|
908
|
+
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
|
909
|
+
img_format.setWidth(original_width)
|
|
910
|
+
img_format.setHeight(original_height)
|
|
911
|
+
|
|
912
|
+
# Add ORIGINAL image to document resources
|
|
913
|
+
self.document().addResource(
|
|
914
|
+
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
# Insert the image at original size
|
|
918
|
+
cursor = self.textCursor()
|
|
919
|
+
cursor.insertImage(img_format)
|
|
920
|
+
cursor.insertText("\n") # Add newline after image
|