|
15 | 15 | QSize, |
16 | 16 | Qt, |
17 | 17 | QTimer, |
| 18 | + QUrl, |
18 | 19 | Signal, |
19 | 20 | ) |
20 | 21 | from PySide6.QtGui import ( |
|
24 | 25 | QEnterEvent, |
25 | 26 | QFont, |
26 | 27 | QIcon, |
| 28 | + QImage, |
27 | 29 | QKeyEvent, |
28 | 30 | QKeySequence, |
29 | 31 | QMouseEvent, |
|
37 | 39 | QTextCursor, |
38 | 40 | QTextDocument, |
39 | 41 | QTextFormat, |
| 42 | + QTextImageFormat, |
40 | 43 | ) |
41 | 44 | from PySide6.QtWidgets import ( |
42 | 45 | QApplication, |
@@ -573,6 +576,87 @@ def __init__(self, tab: EditorTab) -> None: |
573 | 576 | | Qt.TextInteractionFlag.LinksAccessibleByKeyboard |
574 | 577 | ) |
575 | 578 |
|
| 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 | + |
576 | 660 | def _source_range_for_preview_selection(self) -> tuple[int, int]: |
577 | 661 | """Map the current preview selection (or caret) onto source offsets. |
578 | 662 |
|
@@ -1042,7 +1126,8 @@ def refresh_preview(self, *, follow_source_caret: bool = False) -> None: |
1042 | 1126 | source = self.editor.toPlainText() |
1043 | 1127 | source_pos = self.editor.textCursor().position() |
1044 | 1128 |
|
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) |
1046 | 1131 | plain_after = self.preview.toPlainText() |
1047 | 1132 | new_pos = source_pos_to_preview(source, plain_after, source_pos) |
1048 | 1133 |
|
|
0 commit comments