From 3cbfce82575bfe6dc0a13d8b9f348a2a9b0e3d76 Mon Sep 17 00:00:00 2001 From: Lucas K Allmon Date: Sun, 18 Jan 2026 14:34:57 -0800 Subject: [PATCH 1/4] feature (exports): make units more correct --- components/dialogs/ExportDialog.qml | 49 +++++++++++++++------ src/lucent/document_manager.py | 6 ++- tests/test_document_manager.py | 66 ++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/components/dialogs/ExportDialog.qml b/components/dialogs/ExportDialog.qml index e79bfd6..d2465bd 100644 --- a/components/dialogs/ExportDialog.qml +++ b/components/dialogs/ExportDialog.qml @@ -12,12 +12,13 @@ Dialog { title: qsTr("Export Artboard") modal: true standardButtons: Dialog.Cancel + padding: 16 property string artboardId: "" property string artboardName: "Artboard" - // Document DPI is always 72 (points = pixels at 1:1) - readonly property int documentDPI: 72 + // Document DPI controls print scaling; screen exports are 1:1. + readonly property int documentDPI: documentManager ? documentManager.documentDPI : 72 // Get selected DPI value from combo box readonly property int selectedDPI: { @@ -26,11 +27,17 @@ Dialog { return 72; } + readonly property real printBaseDPI: { + if (unitSettings && unitSettings.displayUnit !== "px") + return unitSettings.previewDPI; + return documentDPI; + } + readonly property real computedWidth: { if (!canvasModel || !artboardId) return 0; var bounds = canvasModel.getArtboardBounds(artboardId); - var scale = selectedDPI / documentDPI; + var scale = exportModeCombo.currentIndex === 1 ? selectedDPI / printBaseDPI : 1.0; return (bounds.width + paddingInput.value * 2) * scale; } @@ -38,12 +45,12 @@ Dialog { if (!canvasModel || !artboardId) return 0; var bounds = canvasModel.getArtboardBounds(artboardId); - var scale = selectedDPI / documentDPI; + var scale = exportModeCombo.currentIndex === 1 ? selectedDPI / printBaseDPI : 1.0; return (bounds.height + paddingInput.value * 2) * scale; } width: 360 - height: 320 + implicitHeight: contentLayout.implicitHeight + footerBox.implicitHeight + topPadding + bottomPadding Platform.FileDialog { id: saveDialog @@ -55,17 +62,17 @@ Dialog { if (documentManager) { var bg = transparentCheck.checked ? "" : bgColorPicker.color.toString(); var filePath = saveDialog.file.toString(); - console.log("Exporting to:", filePath, "DPI:", root.selectedDPI); - var result = documentManager.exportArtboard(root.artboardId, filePath, root.selectedDPI, paddingInput.value, bg); + var exportDpi = exportModeCombo.currentIndex === 1 ? root.selectedDPI : root.documentDPI; + console.log("Exporting to:", filePath, "DPI:", exportDpi); + var result = documentManager.exportArtboard(root.artboardId, filePath, exportDpi, paddingInput.value, bg); console.log("Export result:", result); } root.close(); } } - ColumnLayout { - anchors.fill: parent - anchors.margins: 16 + contentItem: ColumnLayout { + id: contentLayout spacing: 12 Label { @@ -89,12 +96,24 @@ Dialog { } Label { - text: qsTr("Resolution:") + text: qsTr("Export mode:") visible: formatCombo.currentIndex === 0 } ComboBox { - id: dpiCombo + id: exportModeCombo visible: formatCombo.currentIndex === 0 + model: [qsTr("Screen (1×)"), qsTr("Print (DPI)")] + currentIndex: 0 + Layout.fillWidth: true + } + + Label { + text: qsTr("Resolution:") + visible: formatCombo.currentIndex === 0 && exportModeCombo.currentIndex === 1 + } + ComboBox { + id: dpiCombo + visible: formatCombo.currentIndex === 0 && exportModeCombo.currentIndex === 1 model: ListModel { ListElement { text: "72 DPI (Screen)" @@ -163,10 +182,14 @@ Dialog { Item { Layout.fillHeight: true } + } + footer: DialogButtonBox { + id: footerBox + standardButtons: Dialog.Cancel Button { text: qsTr("Export...") - Layout.alignment: Qt.AlignRight + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole onClicked: saveDialog.open() } } diff --git a/src/lucent/document_manager.py b/src/lucent/document_manager.py index 801d0d0..ac91aa2 100644 --- a/src/lucent/document_manager.py +++ b/src/lucent/document_manager.py @@ -357,8 +357,12 @@ def exportArtboard( # Empty string means transparent, otherwise use specified color effective_bg = background if background else None + base_dpi = self._document_dpi + if self._unit_settings and self._unit_settings.displayUnit != "px": + base_dpi = int(self._unit_settings.previewDPI) + options = ExportOptions( - document_dpi=self._document_dpi, + document_dpi=base_dpi, target_dpi=target_dpi, padding=padding, background=effective_bg, diff --git a/tests/test_document_manager.py b/tests/test_document_manager.py index ca664c3..0dbc66a 100644 --- a/tests/test_document_manager.py +++ b/tests/test_document_manager.py @@ -11,7 +11,8 @@ from lucent.canvas_model import CanvasModel from lucent.history_manager import HistoryManager from lucent.file_io import LUCENT_VERSION -from test_helpers import make_rectangle +from lucent.unit_settings import UnitSettings +from test_helpers import make_artboard, make_rectangle @pytest.fixture @@ -262,6 +263,69 @@ def test_open_document_clears_dirty( assert doc_manager.dirty is False + +class TestExportArtboard: + """Tests for artboard export scaling behavior.""" + + def test_export_artboard_uses_preview_dpi_for_physical_units( + self, canvas_model: CanvasModel, monkeypatch, tmp_path: Path + ) -> None: + unit_settings = UnitSettings(display_unit="in", preview_dpi=100.0) + doc_manager = DocumentManager(canvas_model, unit_settings) + artboard_id = "artboard-1" + canvas_model.addItem( + make_artboard(artboard_id=artboard_id, width=100, height=50) + ) + + captured = {} + + def fake_export_png(items, bounds, path, options): + captured["document_dpi"] = options.document_dpi + captured["target_dpi"] = options.target_dpi + return True + + import lucent.exporter as exporter + + monkeypatch.setattr(exporter, "export_png", fake_export_png) + + result = doc_manager.exportArtboard( + artboard_id, str(tmp_path / "out.png"), 200, 0, "" + ) + + assert result is True + assert captured["document_dpi"] == 100 + assert captured["target_dpi"] == 200 + + def test_export_artboard_uses_document_dpi_for_pixel_units( + self, canvas_model: CanvasModel, monkeypatch, tmp_path: Path + ) -> None: + unit_settings = UnitSettings(display_unit="px", preview_dpi=96.0) + doc_manager = DocumentManager(canvas_model, unit_settings) + doc_manager.setDocumentDPI(250) + artboard_id = "artboard-2" + canvas_model.addItem( + make_artboard(artboard_id=artboard_id, width=100, height=50) + ) + + captured = {} + + def fake_export_png(items, bounds, path, options): + captured["document_dpi"] = options.document_dpi + captured["target_dpi"] = options.target_dpi + return True + + import lucent.exporter as exporter + + monkeypatch.setattr(exporter, "export_png", fake_export_png) + + result = doc_manager.exportArtboard( + artboard_id, str(tmp_path / "out.png"), 200, 0, "" + ) + + assert result is True + assert captured["document_dpi"] == 250 + assert captured["target_dpi"] == 200 + def test_open_nonexistent_file_returns_false( self, doc_manager: DocumentManager ) -> None: From 9253ac0fa17d0aac39cd7b7c243059d3e3be6188 Mon Sep 17 00:00:00 2001 From: Lucas K Allmon Date: Sun, 18 Jan 2026 14:46:00 -0800 Subject: [PATCH 2/4] feature (export): add main formats --- components/dialogs/ExportDialog.qml | 22 +++++--- src/lucent/document_manager.py | 12 +++- src/lucent/exporter.py | 88 ++++++++++++++++++++++++++++- tests/test_exporter.py | 25 +++++++- 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/components/dialogs/ExportDialog.qml b/components/dialogs/ExportDialog.qml index d2465bd..939f8f4 100644 --- a/components/dialogs/ExportDialog.qml +++ b/components/dialogs/ExportDialog.qml @@ -56,8 +56,8 @@ Dialog { id: saveDialog title: qsTr("Export As") fileMode: Platform.FileDialog.SaveFile - nameFilters: formatCombo.currentIndex === 0 ? ["PNG files (*.png)"] : ["SVG files (*.svg)"] - defaultSuffix: formatCombo.currentIndex === 0 ? "png" : "svg" + nameFilters: formatCombo.currentIndex === 0 ? ["PNG files (*.png)"] : formatCombo.currentIndex === 1 ? ["SVG files (*.svg)"] : formatCombo.currentIndex === 2 ? ["JPG files (*.jpg *.jpeg)"] : ["PDF files (*.pdf)"] + defaultSuffix: formatCombo.currentIndex === 0 ? "png" : formatCombo.currentIndex === 1 ? "svg" : formatCombo.currentIndex === 2 ? "jpg" : "pdf" onAccepted: { if (documentManager) { var bg = transparentCheck.checked ? "" : bgColorPicker.color.toString(); @@ -91,17 +91,22 @@ Dialog { } ComboBox { id: formatCombo - model: ["PNG", "SVG"] + model: ["PNG", "SVG", "JPG", "PDF"] Layout.fillWidth: true + onCurrentIndexChanged: { + if (currentIndex === 2) { + transparentCheck.checked = false; + } + } } Label { text: qsTr("Export mode:") - visible: formatCombo.currentIndex === 0 + visible: formatCombo.currentIndex === 0 || formatCombo.currentIndex === 2 || formatCombo.currentIndex === 3 } ComboBox { id: exportModeCombo - visible: formatCombo.currentIndex === 0 + visible: formatCombo.currentIndex === 0 || formatCombo.currentIndex === 2 || formatCombo.currentIndex === 3 model: [qsTr("Screen (1×)"), qsTr("Print (DPI)")] currentIndex: 0 Layout.fillWidth: true @@ -109,11 +114,11 @@ Dialog { Label { text: qsTr("Resolution:") - visible: formatCombo.currentIndex === 0 && exportModeCombo.currentIndex === 1 + visible: (formatCombo.currentIndex === 0 || formatCombo.currentIndex === 2 || formatCombo.currentIndex === 3) && exportModeCombo.currentIndex === 1 } ComboBox { id: dpiCombo - visible: formatCombo.currentIndex === 0 && exportModeCombo.currentIndex === 1 + visible: (formatCombo.currentIndex === 0 || formatCombo.currentIndex === 2 || formatCombo.currentIndex === 3) && exportModeCombo.currentIndex === 1 model: ListModel { ListElement { text: "72 DPI (Screen)" @@ -155,6 +160,7 @@ Dialog { id: transparentCheck text: qsTr("Transparent") checked: true + enabled: formatCombo.currentIndex !== 2 } Lucent.ColorPickerButton { id: bgColorPicker @@ -175,7 +181,7 @@ Dialog { Label { text: qsTr("Output size: %1 × %2 px").arg(Math.round(root.computedWidth)).arg(Math.round(root.computedHeight)) - visible: formatCombo.currentIndex === 0 + visible: formatCombo.currentIndex === 0 || formatCombo.currentIndex === 2 || formatCombo.currentIndex === 3 color: palette.text } diff --git a/src/lucent/document_manager.py b/src/lucent/document_manager.py index ac91aa2..0d705a4 100644 --- a/src/lucent/document_manager.py +++ b/src/lucent/document_manager.py @@ -340,6 +340,8 @@ def exportArtboard( from lucent.exporter import ( ExportOptions, + export_jpg, + export_pdf, export_png, export_svg, ) @@ -377,7 +379,11 @@ def exportArtboard( ) # Determine format from extension - if local_path.lower().endswith(".svg"): + lower_path = local_path.lower() + if lower_path.endswith(".svg"): return export_svg(items, bounds, local_path, options) - else: - return export_png(items, bounds, local_path, options) + if lower_path.endswith(".pdf"): + return export_pdf(items, bounds, local_path, options) + if lower_path.endswith(".jpg") or lower_path.endswith(".jpeg"): + return export_jpg(items, bounds, local_path, options) + return export_png(items, bounds, local_path, options) diff --git a/src/lucent/exporter.py b/src/lucent/exporter.py index 9415da7..9b2b3b3 100644 --- a/src/lucent/exporter.py +++ b/src/lucent/exporter.py @@ -11,7 +11,15 @@ from typing import List, Optional, TYPE_CHECKING, Union from PySide6.QtCore import QRectF -from PySide6.QtGui import QImage, QPainter, QColor +from PySide6.QtGui import ( + QImage, + QPainter, + QColor, + QPdfWriter, + QPageSize, + QPageLayout, +) +from PySide6.QtCore import QMarginsF, QSizeF if TYPE_CHECKING: from lucent.canvas_items import CanvasItem @@ -112,6 +120,84 @@ def export_png( return False +def export_jpg( + items: List["CanvasItem"], + bounds: QRectF, + path: Union[str, Path], + options: ExportOptions, +) -> bool: + """Export items to a JPG file (opaque).""" + if bounds.isEmpty(): + return False + + width = int(bounds.width() * options.scale) + height = int(bounds.height() * options.scale) + if width <= 0 or height <= 0: + return False + + image = QImage(width, height, QImage.Format.Format_RGB32) + image.fill(QColor(options.background or "#ffffff")) + + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.scale(options.scale, options.scale) + painter.translate(-bounds.x(), -bounds.y()) + + for item in items: + try: + item.paint(painter, zoom_level=1.0, offset_x=0, offset_y=0) + except Exception: + pass + + painter.end() + + try: + return image.save(str(path), b"JPG") + except Exception: + return False + + +def export_pdf( + items: List["CanvasItem"], + bounds: QRectF, + path: Union[str, Path], + options: ExportOptions, +) -> bool: + """Export items to a PDF file.""" + if bounds.isEmpty(): + return False + + width = int(bounds.width() * options.scale) + height = int(bounds.height() * options.scale) + if width <= 0 or height <= 0: + return False + + writer = QPdfWriter(str(path)) + writer.setResolution(int(options.target_dpi)) + width_points = width * 72.0 / options.target_dpi + height_points = height * 72.0 / options.target_dpi + page_size = QPageSize(QSizeF(width_points, height_points), QPageSize.Unit.Point) + writer.setPageSize(page_size) + writer.setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout.Unit.Point) + + painter = QPainter(writer) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.scale(options.scale, options.scale) + painter.translate(-bounds.x(), -bounds.y()) + + if options.background: + painter.fillRect(bounds, QColor(options.background)) + + for item in items: + try: + item.paint(painter, zoom_level=1.0, offset_x=0, offset_y=0) + except Exception: + pass + + painter.end() + return True + + def _item_to_svg_element(item: "CanvasItem") -> Optional[ET.Element]: """Convert a canvas item to an SVG element.""" from lucent.canvas_items import ( diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 60dfdd3..f4a66d1 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -8,7 +8,14 @@ from lucent.appearances import Fill, Stroke from lucent.canvas_items import EllipseItem, PathItem, RectangleItem, TextItem -from lucent.exporter import ExportOptions, compute_bounds, export_png, export_svg +from lucent.exporter import ( + ExportOptions, + compute_bounds, + export_jpg, + export_pdf, + export_png, + export_svg, +) from lucent.exporter import _item_to_svg_element from lucent.geometry import EllipseGeometry, PathGeometry, RectGeometry, TextGeometry @@ -82,6 +89,22 @@ def raise_save(self, *args, **kwargs): assert result is False +def test_export_jpg_writes_file(tmp_path): + options = ExportOptions(background="#ffffff") + bounds = QRectF(0, 0, 10, 10) + result = export_jpg([], bounds, tmp_path / "out.jpg", options) + assert result is True + assert (tmp_path / "out.jpg").exists() + + +def test_export_pdf_writes_file(tmp_path): + options = ExportOptions() + bounds = QRectF(0, 0, 10, 10) + result = export_pdf([], bounds, tmp_path / "out.pdf", options) + assert result is True + assert (tmp_path / "out.pdf").exists() + + def test_item_to_svg_element_rectangle_uniform(): rect = _make_rectangle_item(x=0, y=0, width=100, height=50, corner_radius=10) elem = _item_to_svg_element(rect) From db014de31097eaafb9dcfc769e81ad15125bf186 Mon Sep 17 00:00:00 2001 From: Lucas K Allmon Date: Sun, 18 Jan 2026 15:03:44 -0800 Subject: [PATCH 3/4] fix (SVG): better SVG export support. --- components/dialogs/ExportDialog.qml | 4 +- src/lucent/exporter.py | 159 +++++----------------------- tests/test_exporter.py | 124 +++------------------- 3 files changed, 42 insertions(+), 245 deletions(-) diff --git a/components/dialogs/ExportDialog.qml b/components/dialogs/ExportDialog.qml index 939f8f4..deb8b49 100644 --- a/components/dialogs/ExportDialog.qml +++ b/components/dialogs/ExportDialog.qml @@ -94,7 +94,9 @@ Dialog { model: ["PNG", "SVG", "JPG", "PDF"] Layout.fillWidth: true onCurrentIndexChanged: { - if (currentIndex === 2) { + if (currentIndex === 1) { + transparentCheck.checked = true; + } else if (currentIndex === 2) { transparentCheck.checked = false; } } diff --git a/src/lucent/exporter.py b/src/lucent/exporter.py index 9b2b3b3..763554e 100644 --- a/src/lucent/exporter.py +++ b/src/lucent/exporter.py @@ -5,12 +5,11 @@ from __future__ import annotations -import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path from typing import List, Optional, TYPE_CHECKING, Union -from PySide6.QtCore import QRectF +from PySide6.QtCore import QMarginsF, QRect, QRectF, QSize, QSizeF from PySide6.QtGui import ( QImage, QPainter, @@ -18,14 +17,13 @@ QPdfWriter, QPageSize, QPageLayout, + QImageWriter, ) -from PySide6.QtCore import QMarginsF, QSizeF +from PySide6.QtSvg import QSvgGenerator if TYPE_CHECKING: from lucent.canvas_items import CanvasItem -SVG_NS = "http://www.w3.org/2000/svg" - @dataclass class ExportOptions: @@ -152,7 +150,8 @@ def export_jpg( painter.end() try: - return image.save(str(path), b"JPG") + writer = QImageWriter(str(path), b"jpg") + return writer.write(image) except Exception: return False @@ -198,107 +197,6 @@ def export_pdf( return True -def _item_to_svg_element(item: "CanvasItem") -> Optional[ET.Element]: - """Convert a canvas item to an SVG element.""" - from lucent.canvas_items import ( - RectangleItem, - EllipseItem, - PathItem, - TextItem, - ) - - if isinstance(item, RectangleItem): - geom = item.geometry - tl, tr, br, bl = geom.effective_corner_radii_pixels - stroke = item.stroke - fill = item.fill - - if tl == tr == br == bl: - # Uniform corners - use rect element - elem = ET.Element("rect") - elem.set("x", str(geom.x)) - elem.set("y", str(geom.y)) - elem.set("width", str(geom.width)) - elem.set("height", str(geom.height)) - if tl > 0: - elem.set("rx", str(tl)) - elem.set("ry", str(tl)) - else: - # Per-corner radii - use path element - elem = ET.Element("path") - x, y, w, h = geom.x, geom.y, geom.width, geom.height - d_parts = [f"M {x + tl} {y}"] - d_parts.append(f"L {x + w - tr} {y}") - if tr > 0: - d_parts.append(f"A {tr} {tr} 0 0 1 {x + w} {y + tr}") - d_parts.append(f"L {x + w} {y + h - br}") - if br > 0: - d_parts.append(f"A {br} {br} 0 0 1 {x + w - br} {y + h}") - d_parts.append(f"L {x + bl} {y + h}") - if bl > 0: - d_parts.append(f"A {bl} {bl} 0 0 1 {x} {y + h - bl}") - d_parts.append(f"L {x} {y + tl}") - if tl > 0: - d_parts.append(f"A {tl} {tl} 0 0 1 {x + tl} {y}") - d_parts.append("Z") - elem.set("d", " ".join(d_parts)) - - elem.set("stroke", stroke.color if stroke else "none") - elem.set("stroke-width", str(stroke.width if stroke else 0)) - elem.set("stroke-opacity", str(stroke.opacity if stroke else 0)) - elem.set("fill", fill.color if fill else "none") - elem.set("fill-opacity", str(fill.opacity if fill else 0)) - return elem - - if isinstance(item, EllipseItem): - elem = ET.Element("ellipse") - elem.set("cx", str(item.geometry.center_x)) - elem.set("cy", str(item.geometry.center_y)) - elem.set("rx", str(item.geometry.radius_x)) - elem.set("ry", str(item.geometry.radius_y)) - stroke = item.stroke - fill = item.fill - elem.set("stroke", stroke.color if stroke else "none") - elem.set("stroke-width", str(stroke.width if stroke else 0)) - elem.set("stroke-opacity", str(stroke.opacity if stroke else 0)) - elem.set("fill", fill.color if fill else "none") - elem.set("fill-opacity", str(fill.opacity if fill else 0)) - return elem - - if isinstance(item, PathItem): - elem = ET.Element("path") - points = item.geometry.points - if points: - d_parts = [f"M {points[0]['x']} {points[0]['y']}"] - for p in points[1:]: - d_parts.append(f"L {p['x']} {p['y']}") - if item.geometry.closed: - d_parts.append("Z") - elem.set("d", " ".join(d_parts)) - stroke = item.stroke - fill = item.fill - fill_opacity = fill.opacity if fill else 0 - elem.set("stroke", stroke.color if stroke else "none") - elem.set("stroke-width", str(stroke.width if stroke else 0)) - elem.set("stroke-opacity", str(stroke.opacity if stroke else 0)) - elem.set("fill", fill.color if fill and fill_opacity > 0 else "none") - elem.set("fill-opacity", str(fill_opacity)) - return elem - - if isinstance(item, TextItem): - elem = ET.Element("text") - elem.set("x", str(item.x)) - elem.set("y", str(item.y + item.font_size)) # SVG text y is baseline - elem.set("font-family", item.font_family) - elem.set("font-size", str(item.font_size)) - elem.set("fill", item.text_color) - elem.set("fill-opacity", str(item.text_opacity)) - elem.text = item.text - return elem - - return None - - def export_svg( items: List["CanvasItem"], bounds: QRectF, @@ -319,34 +217,29 @@ def export_svg( if bounds.isEmpty(): return False - ET.register_namespace("", SVG_NS) - svg = ET.Element("svg") - svg.set("xmlns", SVG_NS) - viewbox = ( - f"{int(bounds.x())} {int(bounds.y())} " - f"{int(bounds.width())} {int(bounds.height())}" - ) - svg.set("viewBox", viewbox) - svg.set("width", str(bounds.width())) - svg.set("height", str(bounds.height())) + width = int(bounds.width()) + height = int(bounds.height()) + if width <= 0 or height <= 0: + return False + + generator = QSvgGenerator() + generator.setFileName(str(path)) + generator.setResolution(int(options.target_dpi)) + generator.setSize(QSize(width, height)) + generator.setViewBox(QRect(0, 0, width, height)) + + painter = QPainter(generator) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.translate(-bounds.x(), -bounds.y()) if options.background: - bg = ET.Element("rect") - bg.set("x", str(bounds.x())) - bg.set("y", str(bounds.y())) - bg.set("width", str(bounds.width())) - bg.set("height", str(bounds.height())) - bg.set("fill", options.background) - svg.append(bg) + painter.fillRect(bounds, QColor(options.background)) for item in items: - elem = _item_to_svg_element(item) - if elem is not None: - svg.append(elem) + try: + item.paint(painter, zoom_level=1.0, offset_x=0, offset_y=0) + except Exception: + pass - try: - tree = ET.ElementTree(svg) - tree.write(str(path), encoding="unicode", xml_declaration=True) - return True - except Exception: - return False + painter.end() + return True diff --git a/tests/test_exporter.py b/tests/test_exporter.py index f4a66d1..3f6c568 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -1,23 +1,20 @@ # Copyright (C) 2026 The Culture List, Inc. # SPDX-License-Identifier: GPL-3.0-or-later -import xml.etree.ElementTree as ET - from PySide6.QtCore import QRectF -from PySide6.QtGui import QImage +from PySide6.QtGui import QImage, QImageWriter, QColor +import pytest from lucent.appearances import Fill, Stroke -from lucent.canvas_items import EllipseItem, PathItem, RectangleItem, TextItem +from lucent.canvas_items import RectangleItem from lucent.exporter import ( ExportOptions, compute_bounds, export_jpg, export_pdf, export_png, - export_svg, ) -from lucent.exporter import _item_to_svg_element -from lucent.geometry import EllipseGeometry, PathGeometry, RectGeometry, TextGeometry +from lucent.geometry import RectGeometry class _ExplodingItem: @@ -34,15 +31,6 @@ def _make_rectangle_item(**geom_kwargs): return RectangleItem(geometry=geometry, appearances=appearances) -def _make_path_item(points, closed=False, fill_opacity=1.0): - geometry = PathGeometry(points=points, closed=closed) - appearances = [ - Fill("#ff00ff", fill_opacity, True), - Stroke("#000000", 1.0, 1.0, True), - ] - return PathItem(geometry=geometry, appearances=appearances) - - def test_compute_bounds_padding_expands(): rect = _make_rectangle_item(x=10, y=20, width=30, height=40) bounds = compute_bounds([rect], padding=2) @@ -90,6 +78,15 @@ def raise_save(self, *args, **kwargs): def test_export_jpg_writes_file(tmp_path): + formats = {bytes(fmt).lower() for fmt in QImageWriter.supportedImageFormats()} + if b"jpg" not in formats and b"jpeg" not in formats: + pytest.skip("JPEG image format plugin not available") + probe_path = tmp_path / "_probe.jpg" + probe_writer = QImageWriter(str(probe_path), b"jpg") + probe_image = QImage(1, 1, QImage.Format.Format_RGB32) + probe_image.fill(QColor("#ffffff")) + if not probe_writer.write(probe_image): + pytest.skip(f"JPEG writer unavailable: {probe_writer.errorString()}") options = ExportOptions(background="#ffffff") bounds = QRectF(0, 0, 10, 10) result = export_jpg([], bounds, tmp_path / "out.jpg", options) @@ -103,98 +100,3 @@ def test_export_pdf_writes_file(tmp_path): result = export_pdf([], bounds, tmp_path / "out.pdf", options) assert result is True assert (tmp_path / "out.pdf").exists() - - -def test_item_to_svg_element_rectangle_uniform(): - rect = _make_rectangle_item(x=0, y=0, width=100, height=50, corner_radius=10) - elem = _item_to_svg_element(rect) - assert elem is not None - assert elem.tag == "rect" - assert elem.get("rx") == "5.0" - assert elem.get("ry") == "5.0" - - -def test_item_to_svg_element_rectangle_per_corner(): - rect = _make_rectangle_item( - x=0, - y=0, - width=100, - height=50, - corner_radius_tl=10, - corner_radius_tr=15, - corner_radius_br=20, - corner_radius_bl=5, - ) - elem = _item_to_svg_element(rect) - assert elem is not None - assert elem.tag == "path" - assert "A" in (elem.get("d") or "") - - -def test_item_to_svg_element_path_fill_none_when_transparent(): - path = _make_path_item( - points=[{"x": 0, "y": 0}, {"x": 10, "y": 0}], - closed=False, - fill_opacity=0.0, - ) - elem = _item_to_svg_element(path) - assert elem is not None - assert elem.tag == "path" - assert elem.get("fill") == "none" - - -def test_item_to_svg_element_ellipse(): - ellipse = EllipseItem( - geometry=EllipseGeometry(center_x=50, center_y=50, radius_x=20, radius_y=10), - appearances=[ - Fill("#ffffff", 0.0, True), - Stroke("#ffffff", 1.0, 1.0, True), - ], - ) - elem = _item_to_svg_element(ellipse) - assert elem is not None - assert elem.tag == "ellipse" - assert elem.get("cx") == "50.0" - assert elem.get("rx") == "20.0" - - -def test_item_to_svg_element_text(): - text = TextItem( - geometry=TextGeometry(x=10, y=20, width=100, height=30), - text="Hello", - font_family="Arial", - font_size=12, - text_color="#000000", - ) - elem = _item_to_svg_element(text) - assert elem is not None - assert elem.tag == "text" - assert elem.get("x") == "10.0" - - -def test_export_svg_writes_with_background(tmp_path): - rect = _make_rectangle_item(x=0, y=0, width=10, height=10) - bounds = QRectF(0, 0, 10, 10) - options = ExportOptions(background="#ff0000") - - out_path = tmp_path / "out.svg" - assert export_svg([rect], bounds, out_path, options) is True - - tree = ET.parse(out_path) - root = tree.getroot() - assert root.tag.endswith("svg") - # First child should be background rect - bg = list(root)[0] - assert bg.tag.endswith("rect") - assert bg.get("fill") == "#ff0000" - - -def test_export_svg_returns_false_on_write_error(tmp_path, monkeypatch): - def raise_write(self, *args, **kwargs): - raise RuntimeError("write failed") - - monkeypatch.setattr(ET.ElementTree, "write", raise_write, raising=True) - options = ExportOptions() - bounds = QRectF(0, 0, 10, 10) - result = export_svg([], bounds, tmp_path / "out.svg", options) - assert result is False From 1ef03dbb0ed7cedbca07a0500aace1c824b2655d Mon Sep 17 00:00:00 2001 From: Lucas K Allmon Date: Sun, 18 Jan 2026 16:07:18 -0800 Subject: [PATCH 4/4] chore (coverage): increase test coverage --- tests/test_exporter.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 3f6c568..ecb9716 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -13,6 +13,7 @@ export_jpg, export_pdf, export_png, + export_svg, ) from lucent.geometry import RectGeometry @@ -94,9 +95,37 @@ def test_export_jpg_writes_file(tmp_path): assert (tmp_path / "out.jpg").exists() +def test_export_jpg_returns_false_on_empty_bounds(tmp_path): + options = ExportOptions(background="#ffffff") + result = export_jpg([], QRectF(), tmp_path / "out.jpg", options) + assert result is False + + def test_export_pdf_writes_file(tmp_path): options = ExportOptions() bounds = QRectF(0, 0, 10, 10) result = export_pdf([], bounds, tmp_path / "out.pdf", options) assert result is True assert (tmp_path / "out.pdf").exists() + + +def test_export_pdf_returns_false_on_empty_bounds(tmp_path): + options = ExportOptions() + result = export_pdf([], QRectF(), tmp_path / "out.pdf", options) + assert result is False + + +def test_export_svg_writes_file_with_background(tmp_path): + rect = _make_rectangle_item(x=0, y=0, width=10, height=10) + bounds = QRectF(0, 0, 10, 10) + options = ExportOptions(background="#ff0000") + out_path = tmp_path / "out.svg" + assert export_svg([rect], bounds, out_path, options) is True + assert out_path.exists() + assert out_path.stat().st_size > 0 + + +def test_export_svg_returns_false_on_empty_bounds(tmp_path): + options = ExportOptions() + result = export_svg([], QRectF(), tmp_path / "out.svg", options) + assert result is False