Skip to content

Commit 8409f70

Browse files
Add full GFM support, update assets/screenshot.png
1 parent af454bd commit 8409f70

4 files changed

Lines changed: 148 additions & 8 deletions

File tree

‎assets/screenshot.png‎

13.7 KB
Loading

‎src/morgul/app.py‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
QSize,
1616
Qt,
1717
QTimer,
18+
QUrl,
1819
Signal,
1920
)
2021
from PySide6.QtGui import (
@@ -24,6 +25,7 @@
2425
QEnterEvent,
2526
QFont,
2627
QIcon,
28+
QImage,
2729
QKeyEvent,
2830
QKeySequence,
2931
QMouseEvent,
@@ -37,6 +39,7 @@
3739
QTextCursor,
3840
QTextDocument,
3941
QTextFormat,
42+
QTextImageFormat,
4043
)
4144
from PySide6.QtWidgets import (
4245
QApplication,
@@ -573,6 +576,87 @@ def __init__(self, tab: EditorTab) -> None:
573576
| Qt.TextInteractionFlag.LinksAccessibleByKeyboard
574577
)
575578

579+
def set_preview_html(self, html: str, *, base_dir: Path | None) -> None:
580+
"""Load *html* with a file base URL and scale images to the viewport.
581+
582+
QTextBrowser ignores CSS ``max-width`` / percentage ``width`` on
583+
``<img>``, so large screenshots stay at intrinsic size and look
584+
cropped/off-center. After parse, clamp every image to the pane width.
585+
"""
586+
root = (base_dir if base_dir is not None else Path.cwd()).resolve()
587+
base = QUrl.fromLocalFile(str(root) + "/")
588+
self.document().setBaseUrl(base)
589+
self.setHtml(html)
590+
self.fit_images()
591+
592+
def fit_images(self) -> None:
593+
"""Clamp embedded images to the viewport width (keep aspect ratio)."""
594+
doc = self.document()
595+
max_w = max(float(self.viewport().width() - 8), 32.0)
596+
# Skip no-op format writes when size already matches within half a pixel.
597+
eps = 0.5
598+
block = doc.begin()
599+
while block.isValid():
600+
it = block.begin()
601+
while not it.atEnd():
602+
frag = it.fragment()
603+
if frag.isValid() and frag.charFormat().isImageFormat():
604+
img_fmt = frag.charFormat().toImageFormat()
605+
image = self._image_for_format(img_fmt)
606+
if image is not None and not image.isNull() and image.width() > 0:
607+
iw = float(image.width())
608+
ih = float(image.height())
609+
# Honor explicit pixel widths when they already fit.
610+
cur_w = img_fmt.width()
611+
if cur_w > 0 and cur_w <= max_w:
612+
new_w = cur_w
613+
else:
614+
new_w = min(iw, max_w)
615+
new_h = ih * (new_w / iw)
616+
if (
617+
abs(img_fmt.width() - new_w) >= eps
618+
or abs(img_fmt.height() - new_h) >= eps
619+
):
620+
img_fmt.setWidth(new_w)
621+
img_fmt.setHeight(new_h)
622+
cur = QTextCursor(doc)
623+
cur.setPosition(frag.position())
624+
cur.setPosition(
625+
frag.position() + frag.length(),
626+
QTextCursor.MoveMode.KeepAnchor,
627+
)
628+
cur.setCharFormat(img_fmt)
629+
it += 1
630+
block = block.next()
631+
632+
def _image_for_format(self, img_fmt: QTextImageFormat) -> QImage | None:
633+
"""Resolve a ``QTextImageFormat`` to a ``QImage`` via base URL or path.
634+
635+
Returns:
636+
The loaded image, or ``None`` when the resource cannot be resolved.
637+
"""
638+
name = img_fmt.name()
639+
url = QUrl(name)
640+
if url.isRelative():
641+
url = self.document().baseUrl().resolved(url)
642+
if url.isLocalFile():
643+
image = QImage(url.toLocalFile())
644+
if not image.isNull():
645+
return image
646+
res = self.document().resource(
647+
QTextDocument.ResourceType.ImageResource,
648+
QUrl(name),
649+
)
650+
if isinstance(res, QImage):
651+
return res
652+
return None
653+
654+
@override
655+
def resizeEvent(self, event: QResizeEvent) -> None:
656+
"""Re-fit images when the preview pane width changes."""
657+
super().resizeEvent(event)
658+
self.fit_images()
659+
576660
def _source_range_for_preview_selection(self) -> tuple[int, int]:
577661
"""Map the current preview selection (or caret) onto source offsets.
578662
@@ -1042,7 +1126,8 @@ def refresh_preview(self, *, follow_source_caret: bool = False) -> None:
10421126
source = self.editor.toPlainText()
10431127
source_pos = self.editor.textCursor().position()
10441128

1045-
self.preview.setHtml(to_html(source))
1129+
base_dir = self.path.parent if self.path is not None else None
1130+
self.preview.set_preview_html(to_html(source), base_dir=base_dir)
10461131
plain_after = self.preview.toPlainText()
10471132
new_pos = source_pos_to_preview(source, plain_after, source_pos)
10481133

‎src/morgul/render.py‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,33 @@
88
import re
99

1010
from markdown_it import MarkdownIt
11-
from mdit_py_plugins.tasklists import tasklists_plugin
11+
from mdit_py_plugins.gfm import gfm_plugin
1212

13-
# CommonMark + GFM tables + task lists; raw HTML off for untrusted source.
13+
# GFM preset: tables, strikethrough, autolinks, task lists, footnotes.
14+
# HTML enabled so <img>, <br>, <b>, etc. pass through; tagfilter blocks
15+
# the dangerous tags per GFM spec §6.12.
1416
# ponytail: single shared parser; rebuild if you ever need per-call options.
15-
_PARSER = (
16-
MarkdownIt("commonmark", {"html": False, "linkify": False})
17-
.enable("table")
18-
.use(tasklists_plugin)
17+
_PARSER = MarkdownIt("commonmark", {"html": True, "linkify": False}).use(
18+
gfm_plugin,
1919
)
2020

21+
# GFM "Disallowed Raw HTML" tagfilter (spec §6.12).
22+
# Escapes the opening `<` of dangerous tags; all other HTML passes through.
23+
_DISALLOWED_TAGS = re.compile(
24+
r"<(/?)(title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)\b",
25+
re.IGNORECASE,
26+
)
27+
28+
29+
def _tagfilter(html: str) -> str:
30+
"""Apply GFM tagfilter: escape disallowed raw HTML tags.
31+
32+
Returns:
33+
HTML with ``<`` replaced by ``&lt;`` for blocked tag names.
34+
"""
35+
return _DISALLOWED_TAGS.sub(r"&lt;\1\2", html)
36+
37+
2138
# Loose task syntax people type: ``- [] foo`` (GFM needs a space inside).
2239
_LOOSE_EMPTY_BOX = re.compile(
2340
r"^(\s*(?:[-*+]|\d+[.)])\s+)\[\](\s+)",
@@ -80,6 +97,7 @@
8097
th { background: #2d2d2d; font-weight: 600; }
8198
tr:nth-child(even) td { background: #252526; }
8299
hr { border: none; border-top: 1px solid #484848; }
100+
img { max-width: 100%; height: auto; }
83101
/* Hide the default bullet; the ☑/☐ glyph is the marker. */
84102
ul.contains-task-list { list-style: none; padding-left: 0.35em; }
85103
ul.contains-task-list > li { margin: 0.15em 0; }
@@ -112,7 +130,8 @@ def to_html(source: str) -> str:
112130
Returns:
113131
A self-contained HTML page suitable for ``QTextBrowser.setHtml``.
114132
"""
115-
body = _qtify_tasklists(_PARSER.render(_normalize_task_markdown(source)))
133+
rendered = _PARSER.render(_normalize_task_markdown(source))
134+
body = _qtify_tasklists(_tagfilter(rendered))
116135
return (
117136
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
118137
f"<style>{_CSS}</style></head><body>{body}</body></html>"

‎tests/test_morgul.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,42 @@ def test_to_html_accepts_loose_empty_task_box() -> None:
6161
assert 'type="checkbox"' not in html
6262

6363

64+
def test_to_html_allows_img_tag() -> None:
65+
html = to_html('<img src="pic.png" alt="photo">')
66+
assert '<img src="pic.png" alt="photo">' in html
67+
68+
69+
def test_to_html_allows_br_and_inline_html() -> None:
70+
html = to_html("line<br>break")
71+
assert "<br>" in html
72+
html = to_html("Hello <b>bold</b> and <i>italic</i>")
73+
assert "<b>bold</b>" in html
74+
assert "<i>italic</i>" in html
75+
76+
77+
def test_to_html_tagfilter_blocks_script() -> None:
78+
html = to_html("<script>alert(1)</script>")
79+
assert "<script>" not in html
80+
assert "</script>" not in html
81+
# Only `<` is replaced; `>` passes through.
82+
assert "&lt;script>alert(1)&lt;/script>" in html
83+
84+
85+
def test_to_html_tagfilter_blocks_style_iframe() -> None:
86+
# Check body content only (the document head has its own <style>).
87+
body = to_html("<style>body{}</style>").split("<body>")[1].split("</body>")[0]
88+
assert "<style>" not in body
89+
assert "&lt;style>" in body
90+
body = to_html("<iframe src=x></iframe>").split("<body>")[1].split("</body>")[0]
91+
assert "<iframe" not in body
92+
assert "&lt;iframe" in body
93+
94+
95+
def test_to_html_renders_strikethrough() -> None:
96+
html = to_html("~~deleted~~")
97+
assert "<s>deleted</s>" in html
98+
99+
64100
def test_heading_span() -> None:
65101
spans, fence = spans_in_line("# Title", in_fence=False)
66102
assert fence is False

0 commit comments

Comments
 (0)