From ff335b8b58ec0d93f36aac806b5a166211bab70e Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 2 Jul 2026 21:27:40 +0900 Subject: [PATCH 1/8] Add canvas inspection report --- quickthumb/__init__.py | 8 ++ quickthumb/_inspection.py | 75 ++++++++++++++++ quickthumb/_measurements.py | 1 + quickthumb/_text.py | 34 ++++++++ quickthumb/canvas.py | 5 ++ quickthumb/models.py | 34 ++++++++ tests/test_inspection.py | 165 ++++++++++++++++++++++++++++++++++++ 7 files changed, 322 insertions(+) create mode 100644 quickthumb/_inspection.py create mode 100644 tests/test_inspection.py diff --git a/quickthumb/__init__.py b/quickthumb/__init__.py index 3261336..de6022c 100644 --- a/quickthumb/__init__.py +++ b/quickthumb/__init__.py @@ -11,6 +11,7 @@ BlendMode, Blinds, Box, + CanvasInspection, Checkerboard, Circle, Diagnostic, @@ -24,6 +25,8 @@ GroupLayer, ImageEffect, ImageLayer, + InspectionBBox, + LayerInspection, LinearGradient, OutlineLayer, RadialGradient, @@ -34,6 +37,7 @@ SvgLayer, TextEffect, TextFillImage, + TextInspection, TextLayer, TextPart, Wheel, @@ -60,7 +64,11 @@ "Fade", "Wheel", "Wipe", + "CanvasInspection", "Diagnostic", + "InspectionBBox", + "LayerInspection", + "TextInspection", "GroupLayer", "Background", "BackgroundLayer", diff --git a/quickthumb/_inspection.py b/quickthumb/_inspection.py new file mode 100644 index 0000000..3ce71cb --- /dev/null +++ b/quickthumb/_inspection.py @@ -0,0 +1,75 @@ +from typing import TYPE_CHECKING, Any + +from quickthumb._measurements import BBox, LayerMeasurement, measure_layers +from quickthumb.models import CanvasInspection, InspectionBBox, LayerInspection, TextInspection + +if TYPE_CHECKING: + from quickthumb.canvas import Canvas + + +def inspect_canvas(canvas: "Canvas") -> CanvasInspection: + """Build a deterministic layout inspection report from measured layers.""" + canvas._validate_image_paths() + canvas._ctx.begin_render_pass() + return CanvasInspection( + width=canvas.width, + height=canvas.height, + layers=[_inspect_layer(measured) for measured in measure_layers(canvas)], + ) + + +def _inspect_layer(measured: LayerMeasurement) -> LayerInspection: + return LayerInspection( + id=measured.layer_id, + index=measured.index, + order=measured.order, + z_order=measured.z_order, + type=_layer_type(measured), + name=measured.name, + visible=measured.visible, + bbox=_inspect_bbox(measured.bbox), + text=_inspect_text(measured), + children=[_inspect_layer(child) for child in measured.children], + ) + + +def _layer_type(measured: LayerMeasurement) -> str: + raw_type = getattr(measured.raw_layer, "type", None) + if raw_type: + return str(raw_type) + if measured.raw_layer.__class__.__name__ == "CustomLayer": + return "custom" + return measured.layer_type + + +def _inspect_bbox(box: BBox | None) -> InspectionBBox | None: + if box is None: + return None + return InspectionBBox(x=box.x, y=box.y, width=box.width, height=box.height) + + +def _inspect_text(measured: LayerMeasurement) -> TextInspection | None: + if measured.layer_type != "text": + return None + metadata = measured.metadata + return TextInspection( + wrapped_lines=list(_metadata_tuple(metadata, "wrapped_lines")), + effective_font_size=_metadata_int(metadata, "effective_font_size"), + effective_font_sizes=list(_metadata_tuple(metadata, "effective_font_sizes")), + max_width=metadata.get("max_width"), + auto_scaled=bool(metadata.get("auto_scaled", False)), + ) + + +def _metadata_tuple(metadata: Any, key: str) -> tuple: + value = metadata.get(key, ()) + if isinstance(value, tuple): + return value + if isinstance(value, list): + return tuple(value) + return () + + +def _metadata_int(metadata: Any, key: str) -> int | None: + value = metadata.get(key) + return value if isinstance(value, int) else None diff --git a/quickthumb/_measurements.py b/quickthumb/_measurements.py index 8321cb2..e2ab3a0 100644 --- a/quickthumb/_measurements.py +++ b/quickthumb/_measurements.py @@ -295,6 +295,7 @@ def _measure_text_layer( "position": effective.position, "max_width": effective.max_width, } + details.update(self._text.measure_text_layout(effective)) if metadata: details.update(metadata) return self._measurement( diff --git a/quickthumb/_text.py b/quickthumb/_text.py index 0807fa9..24133d6 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -160,6 +160,40 @@ def effective_layer(self, layer: TextLayer) -> TextLayer: return self._auto_scale_rich_text(layer) return self._auto_scale_simple_text(layer) + def measure_text_layout(self, layer: TextLayer) -> dict[str, object]: + """Return rendered text line and font-size metadata for inspection.""" + if isinstance(layer.content, list): + return self._measure_rich_text_layout(layer) + return self._measure_simple_text_layout(layer) + + def _measure_simple_text_layout(self, layer: TextLayer) -> dict[str, object]: + font = self._fonts.load_font(layer) + content = layer.content if isinstance(layer.content, str) else "" + if layer.max_width: + max_width_px = parse_coordinate(layer.max_width, self._ctx.width) + lines = self._wrap_text(content, font, max_width_px, layer.letter_spacing) + elif "\n" in content: + lines = content.split("\n") + else: + lines = [content] + + return { + "wrapped_lines": tuple(lines), + "effective_font_size": layer.size or DEFAULT_TEXT_SIZE, + "effective_font_sizes": (layer.size or DEFAULT_TEXT_SIZE,), + } + + def _measure_rich_text_layout(self, layer: TextLayer) -> dict[str, object]: + lines = self._prepare_rich_text_lines(layer, apply_wrapping=not layer.auto_scale) + line_text = tuple("".join(part["text"] for part in line) for line in lines) + sizes = tuple(sorted({part["size"] for line in lines for part in line})) + effective_size = min(sizes) if sizes else layer.size or DEFAULT_TEXT_SIZE + return { + "wrapped_lines": line_text, + "effective_font_size": effective_size, + "effective_font_sizes": sizes or (effective_size,), + } + def _render_simple_text(self, image: Image.Image, layer: TextLayer): # Apply auto-scaling if enabled if layer.auto_scale and layer.max_width: diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index 7b35ec4..07a19e2 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -14,6 +14,7 @@ from quickthumb._fonts import FontEngine from quickthumb._groups import GroupEngine from quickthumb._images import ImageEngine +from quickthumb._inspection import inspect_canvas from quickthumb._shapes import ShapeEngine from quickthumb._text import TextEngine from quickthumb.errors import RenderingError, ValidationError @@ -214,6 +215,10 @@ def diagnose(self) -> list: """ return self._diagnostics.diagnose() + def inspect(self): + """Return a deterministic layout report for this canvas without rendering output.""" + return inspect_canvas(self) + @classmethod def from_aspect_ratio(cls, ratio: str, base_width: int) -> Self: width, height = aspect_ratio_dimensions(ratio, base_width) diff --git a/quickthumb/models.py b/quickthumb/models.py index 425884b..3953453 100644 --- a/quickthumb/models.py +++ b/quickthumb/models.py @@ -812,6 +812,40 @@ class Diagnostic(quickthumbModel): message: str +class InspectionBBox(quickthumbModel): + x: int + y: int + width: NonNegativeInt + height: NonNegativeInt + + +class TextInspection(quickthumbModel): + wrapped_lines: list[str] + effective_font_size: PositiveInt | None = None + effective_font_sizes: list[PositiveInt] = [] + max_width: int | str | None = None + auto_scaled: bool = False + + +class LayerInspection(quickthumbModel): + id: str + index: NonNegativeInt + order: NonNegativeInt + z_order: NonNegativeInt + type: str + name: str | None = None + visible: bool + bbox: InspectionBBox | None = None + text: TextInspection | None = None + children: list["LayerInspection"] = [] + + +class CanvasInspection(quickthumbModel): + width: PositiveInt + height: PositiveInt + layers: list[LayerInspection] + + LayerType = Annotated[ BackgroundLayer | TextLayer | OutlineLayer | ImageLayer | ShapeLayer | SvgLayer | GroupLayer, Discriminator("type"), diff --git a/tests/test_inspection.py b/tests/test_inspection.py new file mode 100644 index 0000000..f34f8ce --- /dev/null +++ b/tests/test_inspection.py @@ -0,0 +1,165 @@ +"""Tests for canvas inspection reports (canvas.inspect()).""" + +from pathlib import Path + +from PIL import Image + +FIXTURE_SVG = str(Path(__file__).parent / "fixtures" / "sample.svg") + + +class TestInspectCanvas: + """Test suite for deterministic canvas layout inspection.""" + + def test_should_report_shape_order_identity_and_bbox(self): + """A shape report includes stable identity, order, type, visibility, and bbox.""" + from quickthumb import Canvas + + # given: a background and one positioned shape + canvas = ( + Canvas(200, 100) + .background(color="#FFFFFF") + .shape(shape="rectangle", position=(10, 20), width=30, height=40, color="#FF0000") + ) + + # when + report = canvas.inspect() + + # then + assert report.width == 200 + assert report.height == 100 + assert [layer.id for layer in report.layers] == ["layer:0", "layer:1"] + assert [layer.order for layer in report.layers] == [0, 1] + assert [layer.z_order for layer in report.layers] == [0, 1] + assert report.layers[0].type == "background" + assert report.layers[0].bbox is None + assert report.layers[1].type == "shape" + assert report.layers[1].visible is True + assert report.layers[1].bbox.model_dump() == { + "x": 10, + "y": 20, + "width": 30, + "height": 40, + } + + def test_should_report_text_layout_metadata(self): + """Text reports include wrapped lines and effective font size from measurement.""" + from quickthumb import Canvas + + # given: text with explicit line breaks and a declared font size + canvas = Canvas(300, 200).text( + "First line\nSecond line", + size=32, + color="#000000", + position=(10, 20), + ) + + # when + text = canvas.inspect().layers[0].text + + # then + assert text is not None + assert text.wrapped_lines == ["First line", "Second line"] + assert text.effective_font_size == 32 + assert text.effective_font_sizes == [32] + assert text.auto_scaled is False + + def test_should_report_auto_scaled_text_effective_font_size(self): + """Auto-scaled text reports the reduced effective size used for measurement.""" + from quickthumb import Canvas + + # given: large text constrained to a narrow width + canvas = Canvas(400, 300).text( + "WORDS WRAP HERE", + size=80, + color="#000000", + position=(10, 10), + max_width=150, + auto_scale=True, + ) + + # when + text = canvas.inspect().layers[0].text + + # then + assert text is not None + assert text.auto_scaled is True + assert text.max_width == 150 + assert text.effective_font_size is not None + assert text.effective_font_size < 80 + assert text.wrapped_lines + + def test_should_report_image_and_svg_bounds(self, tmp_path): + """Image and SVG reports expose measured final bboxes.""" + from quickthumb import Canvas + + # given: an image with inferred height and an SVG with explicit dimensions + fixture = tmp_path / "sample.png" + Image.new("RGBA", (80, 40), (0, 255, 0, 255)).save(fixture) + canvas = ( + Canvas(300, 200) + .image(path=str(fixture), position=(20, 30), width=60) + .svg(path=FIXTURE_SVG, position=(100, 40), width=50, height=25) + ) + + # when + report = canvas.inspect() + + # then + image, svg = report.layers + assert image.type == "image" + assert image.bbox.model_dump() == {"x": 20, "y": 30, "width": 60, "height": 30} + assert svg.type == "svg" + assert svg.bbox.model_dump() == {"x": 100, "y": 40, "width": 50, "height": 25} + + def test_should_report_group_children_with_stable_ids(self): + """Group reports include child layout reports with stable path-based IDs.""" + from quickthumb import Canvas + + # given: a group containing a text child and a shape child + canvas = Canvas(300, 200).group( + children=[ + {"type": "text", "content": "Label", "size": 20, "color": "#000000"}, + { + "type": "shape", + "shape": "rectangle", + "width": 30, + "height": 20, + "color": "#FF0000", + }, + ], + position=(15, 25), + gap=5, + ) + + # when + group = canvas.inspect().layers[0] + + # then + assert group.type == "group" + assert group.id == "layer:0" + assert group.bbox is not None + assert [child.id for child in group.children] == ["layer:0:0", "layer:0:1"] + assert [child.type for child in group.children] == ["text", "shape"] + assert group.children[0].text is not None + assert group.children[0].text.wrapped_lines == ["Label"] + + def test_should_report_invisible_layers_without_filtering_them(self): + """Invisible layers stay in the report with visibility set to false.""" + from quickthumb import Canvas + + # given: a transparent shape that still has measurable geometry + canvas = Canvas(100, 100).shape( + shape="rectangle", + position=(70, 80), + width=20, + height=10, + color="#FF0000", + opacity=0, + ) + + # when + layer = canvas.inspect().layers[0] + + # then + assert layer.visible is False + assert layer.bbox.model_dump() == {"x": 70, "y": 80, "width": 20, "height": 10} From b840aebd6956b73425422cd14f44302db428bdd7 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 2 Jul 2026 21:33:47 +0900 Subject: [PATCH 2/8] Apply inspection review fixes --- quickthumb/_inspection.py | 27 ++++--------- quickthumb/_measurements.py | 24 ++++++++++-- tests/test_inspection.py | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/quickthumb/_inspection.py b/quickthumb/_inspection.py index 3ce71cb..646be94 100644 --- a/quickthumb/_inspection.py +++ b/quickthumb/_inspection.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from quickthumb._measurements import BBox, LayerMeasurement, measure_layers from quickthumb.models import CanvasInspection, InspectionBBox, LayerInspection, TextInspection @@ -14,7 +14,10 @@ def inspect_canvas(canvas: "Canvas") -> CanvasInspection: return CanvasInspection( width=canvas.width, height=canvas.height, - layers=[_inspect_layer(measured) for measured in measure_layers(canvas)], + layers=[ + _inspect_layer(measured) + for measured in measure_layers(canvas, include_text_layout=True) + ], ) @@ -53,23 +56,9 @@ def _inspect_text(measured: LayerMeasurement) -> TextInspection | None: return None metadata = measured.metadata return TextInspection( - wrapped_lines=list(_metadata_tuple(metadata, "wrapped_lines")), - effective_font_size=_metadata_int(metadata, "effective_font_size"), - effective_font_sizes=list(_metadata_tuple(metadata, "effective_font_sizes")), + wrapped_lines=list(metadata["wrapped_lines"]), + effective_font_size=metadata["effective_font_size"], + effective_font_sizes=list(metadata["effective_font_sizes"]), max_width=metadata.get("max_width"), auto_scaled=bool(metadata.get("auto_scaled", False)), ) - - -def _metadata_tuple(metadata: Any, key: str) -> tuple: - value = metadata.get(key, ()) - if isinstance(value, tuple): - return value - if isinstance(value, list): - return tuple(value) - return () - - -def _metadata_int(metadata: Any, key: str) -> int | None: - value = metadata.get(key) - return value if isinstance(value, int) else None diff --git a/quickthumb/_measurements.py b/quickthumb/_measurements.py index e2ab3a0..b0d6b57 100644 --- a/quickthumb/_measurements.py +++ b/quickthumb/_measurements.py @@ -14,9 +14,16 @@ MEASURABLE_LAYER_TYPES = frozenset({"text", "shape", "image", "svg", "group"}) -def measure_layers(canvas: "Canvas") -> list["LayerMeasurement"]: +def measure_layers( + canvas: "Canvas", *, include_text_layout: bool = False +) -> list["LayerMeasurement"]: """Measure a canvas's renderable layers into the stable internal contract.""" - engine = LayerMeasurementEngine(canvas._ctx, canvas._groups, canvas._text) + engine = LayerMeasurementEngine( + canvas._ctx, + canvas._groups, + canvas._text, + include_text_layout=include_text_layout, + ) return engine.measure_layers(canvas.layers) @@ -120,10 +127,18 @@ def text_descendants(self) -> Iterable["LayerMeasurement"]: class LayerMeasurementEngine: """Measure layers once into reusable internal layer measurements.""" - def __init__(self, ctx: RenderContext, groups: GroupEngine, text: TextEngine): + def __init__( + self, + ctx: RenderContext, + groups: GroupEngine, + text: TextEngine, + *, + include_text_layout: bool = False, + ): self._ctx = ctx self._groups = groups self._text = text + self._include_text_layout = include_text_layout def measure_layers(self, layers: Iterable[object]) -> list[LayerMeasurement]: measured: list[LayerMeasurement] = [] @@ -295,7 +310,8 @@ def _measure_text_layer( "position": effective.position, "max_width": effective.max_width, } - details.update(self._text.measure_text_layout(effective)) + if self._include_text_layout: + details.update(self._text.measure_text_layout(effective)) if metadata: details.update(metadata) return self._measurement( diff --git a/tests/test_inspection.py b/tests/test_inspection.py index f34f8ce..6ff0d7c 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -88,6 +88,57 @@ def test_should_report_auto_scaled_text_effective_font_size(self): assert text.effective_font_size < 80 assert text.wrapped_lines + def test_should_report_auto_scaled_rich_text_effective_font_sizes(self): + """Auto-scaled rich text reports per-part effective font sizes and lines.""" + from quickthumb import Canvas + + # given: rich text parts with different declared sizes constrained to a narrow width + canvas = Canvas(400, 300).text( + [ + {"text": "ALPHA ", "size": 80, "color": "#000000"}, + {"text": "BETA", "size": 40, "color": "#111111"}, + ], + position=(10, 10), + max_width=180, + auto_scale=True, + ) + + # when + text = canvas.inspect().layers[0].text + + # then + assert text is not None + assert text.auto_scaled is True + assert text.max_width == 180 + assert text.wrapped_lines == ["ALPHA BETA"] + assert text.effective_font_size is not None + assert text.effective_font_size < 40 + assert len(text.effective_font_sizes) == 2 + assert text.effective_font_sizes[0] < 40 + assert text.effective_font_sizes[1] < 80 + + def test_should_keep_text_layout_metadata_inspection_only(self): + """Plain measurement does not compute inspect-only text layout metadata.""" + from quickthumb import Canvas + from quickthumb._measurements import measure_layers + + # given: a text layer with wrapping metadata that inspect can expose + canvas = Canvas(300, 200).text( + "First line\nSecond line", + size=32, + color="#000000", + position=(10, 20), + ) + + # when + measurement = measure_layers(canvas)[0] + inspected = canvas.inspect().layers[0].text + + # then + assert "wrapped_lines" not in measurement.metadata + assert inspected is not None + assert inspected.wrapped_lines == ["First line", "Second line"] + def test_should_report_image_and_svg_bounds(self, tmp_path): """Image and SVG reports expose measured final bboxes.""" from quickthumb import Canvas @@ -143,6 +194,30 @@ def test_should_report_group_children_with_stable_ids(self): assert group.children[0].text is not None assert group.children[0].text.wrapped_lines == ["Label"] + def test_should_report_custom_layers(self): + """Custom layer reports include public identity and no measured geometry.""" + from quickthumb import Canvas + + # given: a named custom layer + canvas = Canvas(100, 100).custom(lambda _image: None, name="noop") + + # when + layer = canvas.inspect().layers[0] + + # then + assert layer.model_dump() == { + "id": "layer:0", + "index": 0, + "order": 0, + "z_order": 0, + "type": "custom", + "name": "noop", + "visible": True, + "bbox": None, + "text": None, + "children": [], + } + def test_should_report_invisible_layers_without_filtering_them(self): """Invisible layers stay in the report with visibility set to false.""" from quickthumb import Canvas From a23d9c6aeaa0f30c11ab59d6b016a41f82dd9324 Mon Sep 17 00:00:00 2001 From: sjquant Date: Thu, 2 Jul 2026 21:35:33 +0900 Subject: [PATCH 3/8] Simplify inspection text layout --- quickthumb/_inspection.py | 29 +++++++++++++++-------------- quickthumb/_measurements.py | 23 +++-------------------- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/quickthumb/_inspection.py b/quickthumb/_inspection.py index 646be94..7f914c4 100644 --- a/quickthumb/_inspection.py +++ b/quickthumb/_inspection.py @@ -4,6 +4,7 @@ from quickthumb.models import CanvasInspection, InspectionBBox, LayerInspection, TextInspection if TYPE_CHECKING: + from quickthumb._text import TextEngine from quickthumb.canvas import Canvas @@ -14,14 +15,11 @@ def inspect_canvas(canvas: "Canvas") -> CanvasInspection: return CanvasInspection( width=canvas.width, height=canvas.height, - layers=[ - _inspect_layer(measured) - for measured in measure_layers(canvas, include_text_layout=True) - ], + layers=[_inspect_layer(measured, canvas._text) for measured in measure_layers(canvas)], ) -def _inspect_layer(measured: LayerMeasurement) -> LayerInspection: +def _inspect_layer(measured: LayerMeasurement, text: "TextEngine") -> LayerInspection: return LayerInspection( id=measured.layer_id, index=measured.index, @@ -31,8 +29,8 @@ def _inspect_layer(measured: LayerMeasurement) -> LayerInspection: name=measured.name, visible=measured.visible, bbox=_inspect_bbox(measured.bbox), - text=_inspect_text(measured), - children=[_inspect_layer(child) for child in measured.children], + text=_inspect_text(measured, text), + children=[_inspect_layer(child, text) for child in measured.children], ) @@ -51,14 +49,17 @@ def _inspect_bbox(box: BBox | None) -> InspectionBBox | None: return InspectionBBox(x=box.x, y=box.y, width=box.width, height=box.height) -def _inspect_text(measured: LayerMeasurement) -> TextInspection | None: +def _inspect_text(measured: LayerMeasurement, text: "TextEngine") -> TextInspection | None: if measured.layer_type != "text": return None - metadata = measured.metadata + layer = measured.effective_text_layer + if layer is None: + return None + layout = text.measure_text_layout(layer) return TextInspection( - wrapped_lines=list(metadata["wrapped_lines"]), - effective_font_size=metadata["effective_font_size"], - effective_font_sizes=list(metadata["effective_font_sizes"]), - max_width=metadata.get("max_width"), - auto_scaled=bool(metadata.get("auto_scaled", False)), + wrapped_lines=list(layout["wrapped_lines"]), + effective_font_size=layout["effective_font_size"], + effective_font_sizes=list(layout["effective_font_sizes"]), + max_width=layer.max_width, + auto_scaled=bool(measured.metadata.get("auto_scaled", False)), ) diff --git a/quickthumb/_measurements.py b/quickthumb/_measurements.py index b0d6b57..8321cb2 100644 --- a/quickthumb/_measurements.py +++ b/quickthumb/_measurements.py @@ -14,16 +14,9 @@ MEASURABLE_LAYER_TYPES = frozenset({"text", "shape", "image", "svg", "group"}) -def measure_layers( - canvas: "Canvas", *, include_text_layout: bool = False -) -> list["LayerMeasurement"]: +def measure_layers(canvas: "Canvas") -> list["LayerMeasurement"]: """Measure a canvas's renderable layers into the stable internal contract.""" - engine = LayerMeasurementEngine( - canvas._ctx, - canvas._groups, - canvas._text, - include_text_layout=include_text_layout, - ) + engine = LayerMeasurementEngine(canvas._ctx, canvas._groups, canvas._text) return engine.measure_layers(canvas.layers) @@ -127,18 +120,10 @@ def text_descendants(self) -> Iterable["LayerMeasurement"]: class LayerMeasurementEngine: """Measure layers once into reusable internal layer measurements.""" - def __init__( - self, - ctx: RenderContext, - groups: GroupEngine, - text: TextEngine, - *, - include_text_layout: bool = False, - ): + def __init__(self, ctx: RenderContext, groups: GroupEngine, text: TextEngine): self._ctx = ctx self._groups = groups self._text = text - self._include_text_layout = include_text_layout def measure_layers(self, layers: Iterable[object]) -> list[LayerMeasurement]: measured: list[LayerMeasurement] = [] @@ -310,8 +295,6 @@ def _measure_text_layer( "position": effective.position, "max_width": effective.max_width, } - if self._include_text_layout: - details.update(self._text.measure_text_layout(effective)) if metadata: details.update(metadata) return self._measurement( From 539a2e6fb996893ed0690f629a3275d34d3ff389 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 16:34:06 +0900 Subject: [PATCH 4/8] Move inspection report into canvas --- quickthumb/_inspection.py | 65 --------------------------------------- quickthumb/canvas.py | 58 ++++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 67 deletions(-) delete mode 100644 quickthumb/_inspection.py diff --git a/quickthumb/_inspection.py b/quickthumb/_inspection.py deleted file mode 100644 index 7f914c4..0000000 --- a/quickthumb/_inspection.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import TYPE_CHECKING - -from quickthumb._measurements import BBox, LayerMeasurement, measure_layers -from quickthumb.models import CanvasInspection, InspectionBBox, LayerInspection, TextInspection - -if TYPE_CHECKING: - from quickthumb._text import TextEngine - from quickthumb.canvas import Canvas - - -def inspect_canvas(canvas: "Canvas") -> CanvasInspection: - """Build a deterministic layout inspection report from measured layers.""" - canvas._validate_image_paths() - canvas._ctx.begin_render_pass() - return CanvasInspection( - width=canvas.width, - height=canvas.height, - layers=[_inspect_layer(measured, canvas._text) for measured in measure_layers(canvas)], - ) - - -def _inspect_layer(measured: LayerMeasurement, text: "TextEngine") -> LayerInspection: - return LayerInspection( - id=measured.layer_id, - index=measured.index, - order=measured.order, - z_order=measured.z_order, - type=_layer_type(measured), - name=measured.name, - visible=measured.visible, - bbox=_inspect_bbox(measured.bbox), - text=_inspect_text(measured, text), - children=[_inspect_layer(child, text) for child in measured.children], - ) - - -def _layer_type(measured: LayerMeasurement) -> str: - raw_type = getattr(measured.raw_layer, "type", None) - if raw_type: - return str(raw_type) - if measured.raw_layer.__class__.__name__ == "CustomLayer": - return "custom" - return measured.layer_type - - -def _inspect_bbox(box: BBox | None) -> InspectionBBox | None: - if box is None: - return None - return InspectionBBox(x=box.x, y=box.y, width=box.width, height=box.height) - - -def _inspect_text(measured: LayerMeasurement, text: "TextEngine") -> TextInspection | None: - if measured.layer_type != "text": - return None - layer = measured.effective_text_layer - if layer is None: - return None - layout = text.measure_text_layout(layer) - return TextInspection( - wrapped_lines=list(layout["wrapped_lines"]), - effective_font_size=layout["effective_font_size"], - effective_font_sizes=list(layout["effective_font_sizes"]), - max_width=layer.max_width, - auto_scaled=bool(measured.metadata.get("auto_scaled", False)), - ) diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index 07a19e2..bcdb35c 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -14,7 +14,7 @@ from quickthumb._fonts import FontEngine from quickthumb._groups import GroupEngine from quickthumb._images import ImageEngine -from quickthumb._inspection import inspect_canvas +from quickthumb._measurements import BBox, LayerMeasurement, measure_layers from quickthumb._shapes import ShapeEngine from quickthumb._text import TextEngine from quickthumb.errors import RenderingError, ValidationError @@ -24,11 +24,14 @@ BackgroundEffect, BackgroundLayer, BlendMode, + CanvasInspection, FitMode, Grain, GroupLayer, ImageEffect, ImageLayer, + InspectionBBox, + LayerInspection, LayerType, LinearGradient, OutlineLayer, @@ -37,6 +40,7 @@ ShapeLayer, SvgLayer, TextFillImage, + TextInspection, TextLayer, TextPart, ) @@ -217,7 +221,57 @@ def diagnose(self) -> list: def inspect(self): """Return a deterministic layout report for this canvas without rendering output.""" - return inspect_canvas(self) + self._validate_image_paths() + self._ctx.begin_render_pass() + return CanvasInspection( + width=self.width, + height=self.height, + layers=[self._inspect_layer(measured) for measured in measure_layers(self)], + ) + + def _inspect_layer(self, measured: LayerMeasurement) -> LayerInspection: + return LayerInspection( + id=measured.layer_id, + index=measured.index, + order=measured.order, + z_order=measured.z_order, + type=self._inspect_layer_type(measured), + name=measured.name, + visible=measured.visible, + bbox=self._inspect_bbox(measured.bbox), + text=self._inspect_text(measured), + children=[self._inspect_layer(child) for child in measured.children], + ) + + @staticmethod + def _inspect_layer_type(measured: LayerMeasurement) -> str: + raw_type = getattr(measured.raw_layer, "type", None) + if raw_type: + return str(raw_type) + if isinstance(measured.raw_layer, CustomLayer): + return "custom" + return measured.layer_type + + @staticmethod + def _inspect_bbox(box: BBox | None) -> InspectionBBox | None: + if box is None: + return None + return InspectionBBox(x=box.x, y=box.y, width=box.width, height=box.height) + + def _inspect_text(self, measured: LayerMeasurement) -> TextInspection | None: + if measured.layer_type != "text": + return None + layer = measured.effective_text_layer + if layer is None: + return None + layout = self._text.measure_text_layout(layer) + return TextInspection( + wrapped_lines=list(layout["wrapped_lines"]), + effective_font_size=layout["effective_font_size"], + effective_font_sizes=list(layout["effective_font_sizes"]), + max_width=layer.max_width, + auto_scaled=bool(measured.metadata.get("auto_scaled", False)), + ) @classmethod def from_aspect_ratio(cls, ratio: str, base_width: int) -> Self: From 527ef085adb3f91f188eef49568a6b4f1f9c2837 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 16:36:44 +0900 Subject: [PATCH 5/8] Improve inspection typing --- quickthumb/_text.py | 12 +++++++++--- quickthumb/canvas.py | 5 +++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/quickthumb/_text.py b/quickthumb/_text.py index 24133d6..a775b6c 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -56,6 +56,12 @@ class TextPartMetadata(TypedDict): width: int +class TextLayoutMetadata(TypedDict): + wrapped_lines: tuple[str, ...] + effective_font_size: int + effective_font_sizes: tuple[int, ...] + + class TextEngine: """Text layout, measurement, wrapping, effects, and rendering.""" @@ -160,13 +166,13 @@ def effective_layer(self, layer: TextLayer) -> TextLayer: return self._auto_scale_rich_text(layer) return self._auto_scale_simple_text(layer) - def measure_text_layout(self, layer: TextLayer) -> dict[str, object]: + def measure_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: """Return rendered text line and font-size metadata for inspection.""" if isinstance(layer.content, list): return self._measure_rich_text_layout(layer) return self._measure_simple_text_layout(layer) - def _measure_simple_text_layout(self, layer: TextLayer) -> dict[str, object]: + def _measure_simple_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: font = self._fonts.load_font(layer) content = layer.content if isinstance(layer.content, str) else "" if layer.max_width: @@ -183,7 +189,7 @@ def _measure_simple_text_layout(self, layer: TextLayer) -> dict[str, object]: "effective_font_sizes": (layer.size or DEFAULT_TEXT_SIZE,), } - def _measure_rich_text_layout(self, layer: TextLayer) -> dict[str, object]: + def _measure_rich_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: lines = self._prepare_rich_text_lines(layer, apply_wrapping=not layer.auto_scale) line_text = tuple("".join(part["text"] for part in line) for line in lines) sizes = tuple(sorted({part["size"] for line in lines for part in line})) diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index bcdb35c..2c94f20 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -25,6 +25,7 @@ BackgroundLayer, BlendMode, CanvasInspection, + Diagnostic, FitMode, Grain, GroupLayer, @@ -211,7 +212,7 @@ def layers(self) -> list[RenderableLayer]: def layers(self, value: list[RenderableLayer]): self._layers = value - def diagnose(self) -> list: + def diagnose(self) -> list[Diagnostic]: """Check layers for layout and legibility issues without producing an output file. Returns structured findings (off-canvas, tiny-text, text-overflow, low-contrast) @@ -219,7 +220,7 @@ def diagnose(self) -> list: """ return self._diagnostics.diagnose() - def inspect(self): + def inspect(self) -> CanvasInspection: """Return a deterministic layout report for this canvas without rendering output.""" self._validate_image_paths() self._ctx.begin_render_pass() From dc8124d2afb97fba4f102d8fdf30790540470503 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 16:45:40 +0900 Subject: [PATCH 6/8] Apply inspection review fixes --- quickthumb/_measurements.py | 6 +- quickthumb/_text.py | 43 +++----- quickthumb/canvas.py | 35 +++--- tests/test_inspection.py | 208 +++++++++++++++++++++--------------- 4 files changed, 158 insertions(+), 134 deletions(-) diff --git a/quickthumb/_measurements.py b/quickthumb/_measurements.py index 8321cb2..940a04a 100644 --- a/quickthumb/_measurements.py +++ b/quickthumb/_measurements.py @@ -281,7 +281,8 @@ def _measure_text_layer( metadata: Mapping[str, Any] | None = None, ) -> LayerMeasurement: effective = self._text.effective_layer(layer) - w, h = self._groups.measure_group_child(effective) + layout = self._text.measure_text_layout(effective) + w, h = layout["size"] base_x, base_y = self._text.get_text_base_position(effective) x = self._text.get_horizontal_start_x(base_x, w, effective.align) y = self._text.get_vertical_start_y(base_y, h, effective.align) @@ -294,6 +295,9 @@ def _measure_text_layer( "align": effective.align, "position": effective.position, "max_width": effective.max_width, + "wrapped_lines": layout["wrapped_lines"], + "effective_font_size": layout["effective_font_size"], + "effective_font_sizes": layout["effective_font_sizes"], } if metadata: details.update(metadata) diff --git a/quickthumb/_text.py b/quickthumb/_text.py index a775b6c..f5dac6b 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -57,6 +57,7 @@ class TextPartMetadata(TypedDict): class TextLayoutMetadata(TypedDict): + size: tuple[int, int] wrapped_lines: tuple[str, ...] effective_font_size: int effective_font_sizes: tuple[int, ...] @@ -182,8 +183,15 @@ def _measure_simple_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: lines = content.split("\n") else: lines = [content] + size = self.measure_text_bounds( + "\n".join(lines), + font, + layer.letter_spacing or 0, + layer.line_height or DEFAULT_LINE_HEIGHT_MULTIPLIER, + ) return { + "size": size, "wrapped_lines": tuple(lines), "effective_font_size": layer.size or DEFAULT_TEXT_SIZE, "effective_font_sizes": (layer.size or DEFAULT_TEXT_SIZE,), @@ -194,7 +202,13 @@ def _measure_rich_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: line_text = tuple("".join(part["text"] for part in line) for line in lines) sizes = tuple(sorted({part["size"] for line in lines for part in line})) effective_size = min(sizes) if sizes else layer.size or DEFAULT_TEXT_SIZE + _, total_height = self._calculate_rich_text_dimensions(layer, lines) + size = ( + max((self._measure_rich_line_width(line_parts) for line_parts in lines), default=0), + total_height, + ) return { + "size": size, "wrapped_lines": line_text, "effective_font_size": effective_size, "effective_font_sizes": sizes or (effective_size,), @@ -998,24 +1012,7 @@ def measure_simple_text_size( self, layer: TextLayer, font: FontType, content: str ) -> tuple[int, int]: """Calculate text bounding box size accounting for wrapping.""" - line_height_mult = layer.line_height or DEFAULT_LINE_HEIGHT_MULTIPLIER - - if layer.max_width: - max_width_px = parse_coordinate(layer.max_width, self._ctx.width) - lines = self._wrap_text(content, font, max_width_px, layer.letter_spacing) - return self.measure_text_bounds( - "\n".join(lines), - font, - layer.letter_spacing or 0, - line_height_mult, - ) - - return self.measure_text_bounds( - content, - font, - layer.letter_spacing or 0, - line_height_mult, - ) + return self.measure_text_layout(layer)["size"] def _render_rotated_rich_text(self, image: Image.Image, layer: TextLayer): """Render rich text with rotation applied. @@ -1045,15 +1042,7 @@ def _render_rotated_rich_text(self, image: Image.Image, layer: TextLayer): def measure_rich_text_size(self, layer: TextLayer) -> tuple[int, int]: """Calculate rich text bounding box size.""" - lines = self._prepare_rich_text_lines(layer) - _, total_height = self._calculate_rich_text_dimensions(layer, lines) - - max_width = 0 - for line_parts in lines: - line_width = self._measure_rich_line_width(line_parts) - max_width = max(max_width, line_width) - - return max_width, total_height + return self.measure_text_layout(layer)["size"] def _calculate_rich_text_effects_padding(self, layer: TextLayer) -> int: """Calculate padding for rich text effects from both layer and part-level effects.""" diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index 2c94f20..d244d1a 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -14,7 +14,7 @@ from quickthumb._fonts import FontEngine from quickthumb._groups import GroupEngine from quickthumb._images import ImageEngine -from quickthumb._measurements import BBox, LayerMeasurement, measure_layers +from quickthumb._measurements import LayerMeasurement, measure_layers from quickthumb._shapes import ShapeEngine from quickthumb._text import TextEngine from quickthumb.errors import RenderingError, ValidationError @@ -230,18 +230,26 @@ def inspect(self) -> CanvasInspection: layers=[self._inspect_layer(measured) for measured in measure_layers(self)], ) - def _inspect_layer(self, measured: LayerMeasurement) -> LayerInspection: + def _inspect_layer( + self, measured: LayerMeasurement, index: int | None = None, order: int | None = None + ) -> LayerInspection: + box = measured.bbox return LayerInspection( id=measured.layer_id, - index=measured.index, - order=measured.order, - z_order=measured.z_order, + index=measured.index if index is None else index, + order=measured.order if order is None else order, + z_order=measured.z_order if order is None else order, type=self._inspect_layer_type(measured), name=measured.name, visible=measured.visible, - bbox=self._inspect_bbox(measured.bbox), + bbox=None + if box is None + else InspectionBBox(x=box.x, y=box.y, width=box.width, height=box.height), text=self._inspect_text(measured), - children=[self._inspect_layer(child) for child in measured.children], + children=[ + self._inspect_layer(child, index=child_index, order=child_index) + for child_index, child in enumerate(measured.children) + ], ) @staticmethod @@ -253,23 +261,16 @@ def _inspect_layer_type(measured: LayerMeasurement) -> str: return "custom" return measured.layer_type - @staticmethod - def _inspect_bbox(box: BBox | None) -> InspectionBBox | None: - if box is None: - return None - return InspectionBBox(x=box.x, y=box.y, width=box.width, height=box.height) - def _inspect_text(self, measured: LayerMeasurement) -> TextInspection | None: if measured.layer_type != "text": return None layer = measured.effective_text_layer if layer is None: return None - layout = self._text.measure_text_layout(layer) return TextInspection( - wrapped_lines=list(layout["wrapped_lines"]), - effective_font_size=layout["effective_font_size"], - effective_font_sizes=list(layout["effective_font_sizes"]), + wrapped_lines=list(measured.metadata["wrapped_lines"]), + effective_font_size=measured.metadata["effective_font_size"], + effective_font_sizes=list(measured.metadata["effective_font_sizes"]), max_width=layer.max_width, auto_scaled=bool(measured.metadata.get("auto_scaled", False)), ) diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 6ff0d7c..bf1fb2d 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -12,7 +12,7 @@ class TestInspectCanvas: def test_should_report_shape_order_identity_and_bbox(self): """A shape report includes stable identity, order, type, visibility, and bbox.""" - from quickthumb import Canvas + from quickthumb import Canvas, CanvasInspection, InspectionBBox, LayerInspection # given: a background and one positioned shape canvas = ( @@ -25,25 +25,33 @@ def test_should_report_shape_order_identity_and_bbox(self): report = canvas.inspect() # then - assert report.width == 200 - assert report.height == 100 - assert [layer.id for layer in report.layers] == ["layer:0", "layer:1"] - assert [layer.order for layer in report.layers] == [0, 1] - assert [layer.z_order for layer in report.layers] == [0, 1] - assert report.layers[0].type == "background" - assert report.layers[0].bbox is None - assert report.layers[1].type == "shape" - assert report.layers[1].visible is True - assert report.layers[1].bbox.model_dump() == { - "x": 10, - "y": 20, - "width": 30, - "height": 40, - } + assert report == CanvasInspection( + width=200, + height=100, + layers=[ + LayerInspection( + id="layer:0", + index=0, + order=0, + z_order=0, + type="background", + visible=True, + ), + LayerInspection( + id="layer:1", + index=1, + order=1, + z_order=1, + type="shape", + visible=True, + bbox=InspectionBBox(x=10, y=20, width=30, height=40), + ), + ], + ) def test_should_report_text_layout_metadata(self): """Text reports include wrapped lines and effective font size from measurement.""" - from quickthumb import Canvas + from quickthumb import Canvas, TextInspection # given: text with explicit line breaks and a declared font size canvas = Canvas(300, 200).text( @@ -57,15 +65,15 @@ def test_should_report_text_layout_metadata(self): text = canvas.inspect().layers[0].text # then - assert text is not None - assert text.wrapped_lines == ["First line", "Second line"] - assert text.effective_font_size == 32 - assert text.effective_font_sizes == [32] - assert text.auto_scaled is False + assert text == TextInspection( + wrapped_lines=["First line", "Second line"], + effective_font_size=32, + effective_font_sizes=[32], + ) def test_should_report_auto_scaled_text_effective_font_size(self): """Auto-scaled text reports the reduced effective size used for measurement.""" - from quickthumb import Canvas + from quickthumb import Canvas, TextInspection # given: large text constrained to a narrow width canvas = Canvas(400, 300).text( @@ -81,16 +89,17 @@ def test_should_report_auto_scaled_text_effective_font_size(self): text = canvas.inspect().layers[0].text # then - assert text is not None - assert text.auto_scaled is True - assert text.max_width == 150 - assert text.effective_font_size is not None - assert text.effective_font_size < 80 - assert text.wrapped_lines + assert text == TextInspection( + wrapped_lines=["WORDS", "WRAP", "HERE"], + effective_font_size=41, + effective_font_sizes=[41], + max_width=150, + auto_scaled=True, + ) def test_should_report_auto_scaled_rich_text_effective_font_sizes(self): """Auto-scaled rich text reports per-part effective font sizes and lines.""" - from quickthumb import Canvas + from quickthumb import Canvas, TextInspection # given: rich text parts with different declared sizes constrained to a narrow width canvas = Canvas(400, 300).text( @@ -107,41 +116,17 @@ def test_should_report_auto_scaled_rich_text_effective_font_sizes(self): text = canvas.inspect().layers[0].text # then - assert text is not None - assert text.auto_scaled is True - assert text.max_width == 180 - assert text.wrapped_lines == ["ALPHA BETA"] - assert text.effective_font_size is not None - assert text.effective_font_size < 40 - assert len(text.effective_font_sizes) == 2 - assert text.effective_font_sizes[0] < 40 - assert text.effective_font_sizes[1] < 80 - - def test_should_keep_text_layout_metadata_inspection_only(self): - """Plain measurement does not compute inspect-only text layout metadata.""" - from quickthumb import Canvas - from quickthumb._measurements import measure_layers - - # given: a text layer with wrapping metadata that inspect can expose - canvas = Canvas(300, 200).text( - "First line\nSecond line", - size=32, - color="#000000", - position=(10, 20), + assert text == TextInspection( + wrapped_lines=["ALPHA BETA"], + effective_font_size=17, + effective_font_sizes=[17, 35], + max_width=180, + auto_scaled=True, ) - # when - measurement = measure_layers(canvas)[0] - inspected = canvas.inspect().layers[0].text - - # then - assert "wrapped_lines" not in measurement.metadata - assert inspected is not None - assert inspected.wrapped_lines == ["First line", "Second line"] - def test_should_report_image_and_svg_bounds(self, tmp_path): """Image and SVG reports expose measured final bboxes.""" - from quickthumb import Canvas + from quickthumb import Canvas, InspectionBBox, LayerInspection # given: an image with inferred height and an SVG with explicit dimensions fixture = tmp_path / "sample.png" @@ -157,14 +142,28 @@ def test_should_report_image_and_svg_bounds(self, tmp_path): # then image, svg = report.layers - assert image.type == "image" - assert image.bbox.model_dump() == {"x": 20, "y": 30, "width": 60, "height": 30} - assert svg.type == "svg" - assert svg.bbox.model_dump() == {"x": 100, "y": 40, "width": 50, "height": 25} + assert image == LayerInspection( + id="layer:0", + index=0, + order=0, + z_order=0, + type="image", + visible=True, + bbox=InspectionBBox(x=20, y=30, width=60, height=30), + ) + assert svg == LayerInspection( + id="layer:1", + index=1, + order=1, + z_order=1, + type="svg", + visible=True, + bbox=InspectionBBox(x=100, y=40, width=50, height=25), + ) def test_should_report_group_children_with_stable_ids(self): """Group reports include child layout reports with stable path-based IDs.""" - from quickthumb import Canvas + from quickthumb import Canvas, InspectionBBox, LayerInspection, TextInspection # given: a group containing a text child and a shape child canvas = Canvas(300, 200).group( @@ -186,17 +185,44 @@ def test_should_report_group_children_with_stable_ids(self): group = canvas.inspect().layers[0] # then - assert group.type == "group" - assert group.id == "layer:0" - assert group.bbox is not None - assert [child.id for child in group.children] == ["layer:0:0", "layer:0:1"] - assert [child.type for child in group.children] == ["text", "shape"] - assert group.children[0].text is not None - assert group.children[0].text.wrapped_lines == ["Label"] + assert group == LayerInspection( + id="layer:0", + index=0, + order=0, + z_order=0, + type="group", + visible=True, + bbox=InspectionBBox(x=15, y=25, width=49, height=42), + children=[ + LayerInspection( + id="layer:0:0", + index=0, + order=0, + z_order=0, + type="text", + visible=True, + bbox=InspectionBBox(x=15, y=25, width=49, height=17), + text=TextInspection( + wrapped_lines=["Label"], + effective_font_size=20, + effective_font_sizes=[20], + ), + ), + LayerInspection( + id="layer:0:1", + index=1, + order=1, + z_order=1, + type="shape", + visible=True, + bbox=InspectionBBox(x=15, y=47, width=30, height=20), + ), + ], + ) def test_should_report_custom_layers(self): """Custom layer reports include public identity and no measured geometry.""" - from quickthumb import Canvas + from quickthumb import Canvas, LayerInspection # given: a named custom layer canvas = Canvas(100, 100).custom(lambda _image: None, name="noop") @@ -205,22 +231,19 @@ def test_should_report_custom_layers(self): layer = canvas.inspect().layers[0] # then - assert layer.model_dump() == { - "id": "layer:0", - "index": 0, - "order": 0, - "z_order": 0, - "type": "custom", - "name": "noop", - "visible": True, - "bbox": None, - "text": None, - "children": [], - } + assert layer == LayerInspection( + id="layer:0", + index=0, + order=0, + z_order=0, + type="custom", + name="noop", + visible=True, + ) def test_should_report_invisible_layers_without_filtering_them(self): """Invisible layers stay in the report with visibility set to false.""" - from quickthumb import Canvas + from quickthumb import Canvas, InspectionBBox, LayerInspection # given: a transparent shape that still has measurable geometry canvas = Canvas(100, 100).shape( @@ -236,5 +259,12 @@ def test_should_report_invisible_layers_without_filtering_them(self): layer = canvas.inspect().layers[0] # then - assert layer.visible is False - assert layer.bbox.model_dump() == {"x": 70, "y": 80, "width": 20, "height": 10} + assert layer == LayerInspection( + id="layer:0", + index=0, + order=0, + z_order=0, + type="shape", + visible=False, + bbox=InspectionBBox(x=70, y=80, width=20, height=10), + ) From 22fe49cef5f4344c8a214e8011dc04db09bca0fc Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 16:49:04 +0900 Subject: [PATCH 7/8] Simplify text measurement sizing API --- quickthumb/_export_base.py | 4 +--- quickthumb/_groups.py | 3 +-- quickthumb/_text.py | 9 ++------- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/quickthumb/_export_base.py b/quickthumb/_export_base.py index d149bc1..2b04c2b 100644 --- a/quickthumb/_export_base.py +++ b/quickthumb/_export_base.py @@ -406,9 +406,7 @@ def _compute_rotated_layout(canvas: Canvas, layer: TextLayer) -> TextBlockLayout text_w, text_h = text.measure_rich_text_size(layer) padding = text._calculate_rich_text_effects_padding(layer) else: - font = canvas._fonts.load_font(layer) - content = layer.content if isinstance(layer.content, str) else "" - text_w, text_h = text.measure_simple_text_size(layer, font, content) + text_w, text_h = text.measure_simple_text_size(layer) padding = text._calculate_text_effects_padding( text._get_stroke_effects(layer.effects), text._get_shadow_effects(layer.effects), diff --git a/quickthumb/_groups.py b/quickthumb/_groups.py index 468d97e..004c998 100644 --- a/quickthumb/_groups.py +++ b/quickthumb/_groups.py @@ -177,8 +177,7 @@ def _measure_group_child_uncached(self, child: GroupChildLayer) -> tuple[int, in child = self._text.effective_layer(child) if isinstance(child.content, list): return self._text.measure_rich_text_size(child) - font = self._fonts.load_font(child) - return self._text.measure_simple_text_size(child, font, child.content) + return self._text.measure_simple_text_size(child) if isinstance(child, ImageLayer): return expanded_rotation_size(self._measure_image_size(child), child.rotation) if isinstance(child, SvgLayer): diff --git a/quickthumb/_text.py b/quickthumb/_text.py index f5dac6b..5cef7d4 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -985,14 +985,11 @@ def _render_rotated_simple_text(self, image: Image.Image, layer: TextLayer): then compositing the result onto the main canvas. This approach preserves all text effects during rotation. """ - font = self._fonts.load_font(layer) - content = layer.content if isinstance(layer.content, str) else "" - stroke_effects = self._get_stroke_effects(layer.effects) shadow_effects = self._get_shadow_effects(layer.effects) glow_effects = self._get_glow_effects(layer.effects) - text_width, text_height = self.measure_simple_text_size(layer, font, content) + text_width, text_height = self.measure_simple_text_size(layer) padding = self._calculate_text_effects_padding(stroke_effects, shadow_effects, glow_effects) temp_image, _ = self._create_temp_image_for_text(text_width, text_height, padding) @@ -1008,9 +1005,7 @@ def _render_rotated_simple_text(self, image: Image.Image, layer: TextLayer): self._render_simple_text(temp_image, temp_layer) self._rotate_and_composite_text(image, temp_image, layer) - def measure_simple_text_size( - self, layer: TextLayer, font: FontType, content: str - ) -> tuple[int, int]: + def measure_simple_text_size(self, layer: TextLayer) -> tuple[int, int]: """Calculate text bounding box size accounting for wrapping.""" return self.measure_text_layout(layer)["size"] From 771a9dbafb72b6659a79bff3aaa398579666f118 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sat, 4 Jul 2026 16:52:45 +0900 Subject: [PATCH 8/8] Collapse text size measurement wrappers --- quickthumb/_export_base.py | 3 +-- quickthumb/_groups.py | 4 +--- quickthumb/_text.py | 12 ++++-------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/quickthumb/_export_base.py b/quickthumb/_export_base.py index 2b04c2b..7f37a32 100644 --- a/quickthumb/_export_base.py +++ b/quickthumb/_export_base.py @@ -402,11 +402,10 @@ def _compute_rotated_layout(canvas: Canvas, layer: TextLayer) -> TextBlockLayout """Mirror the engine's rotate-a-staging-image path as a transform description.""" text = canvas._text + text_w, text_h = text.measure_text_size(layer) if isinstance(layer.content, list): - text_w, text_h = text.measure_rich_text_size(layer) padding = text._calculate_rich_text_effects_padding(layer) else: - text_w, text_h = text.measure_simple_text_size(layer) padding = text._calculate_text_effects_padding( text._get_stroke_effects(layer.effects), text._get_shadow_effects(layer.effects), diff --git a/quickthumb/_groups.py b/quickthumb/_groups.py index 004c998..9010aaa 100644 --- a/quickthumb/_groups.py +++ b/quickthumb/_groups.py @@ -175,9 +175,7 @@ def measure_group_child(self, child: GroupChildLayer) -> tuple[int, int]: def _measure_group_child_uncached(self, child: GroupChildLayer) -> tuple[int, int]: if isinstance(child, TextLayer): child = self._text.effective_layer(child) - if isinstance(child.content, list): - return self._text.measure_rich_text_size(child) - return self._text.measure_simple_text_size(child) + return self._text.measure_text_size(child) if isinstance(child, ImageLayer): return expanded_rotation_size(self._measure_image_size(child), child.rotation) if isinstance(child, SvgLayer): diff --git a/quickthumb/_text.py b/quickthumb/_text.py index 5cef7d4..4c85eae 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -989,7 +989,7 @@ def _render_rotated_simple_text(self, image: Image.Image, layer: TextLayer): shadow_effects = self._get_shadow_effects(layer.effects) glow_effects = self._get_glow_effects(layer.effects) - text_width, text_height = self.measure_simple_text_size(layer) + text_width, text_height = self.measure_text_size(layer) padding = self._calculate_text_effects_padding(stroke_effects, shadow_effects, glow_effects) temp_image, _ = self._create_temp_image_for_text(text_width, text_height, padding) @@ -1005,8 +1005,8 @@ def _render_rotated_simple_text(self, image: Image.Image, layer: TextLayer): self._render_simple_text(temp_image, temp_layer) self._rotate_and_composite_text(image, temp_image, layer) - def measure_simple_text_size(self, layer: TextLayer) -> tuple[int, int]: - """Calculate text bounding box size accounting for wrapping.""" + def measure_text_size(self, layer: TextLayer) -> tuple[int, int]: + """Calculate rendered text bounding box size accounting for wrapping.""" return self.measure_text_layout(layer)["size"] def _render_rotated_rich_text(self, image: Image.Image, layer: TextLayer): @@ -1019,7 +1019,7 @@ def _render_rotated_rich_text(self, image: Image.Image, layer: TextLayer): if not isinstance(layer.content, list): return - text_width, text_height = self.measure_rich_text_size(layer) + text_width, text_height = self.measure_text_size(layer) padding = self._calculate_rich_text_effects_padding(layer) temp_image, _ = self._create_temp_image_for_text(text_width, text_height, padding) @@ -1035,10 +1035,6 @@ def _render_rotated_rich_text(self, image: Image.Image, layer: TextLayer): self._render_rich_text(temp_image, temp_layer) self._rotate_and_composite_text(image, temp_image, layer) - def measure_rich_text_size(self, layer: TextLayer) -> tuple[int, int]: - """Calculate rich text bounding box size.""" - return self.measure_text_layout(layer)["size"] - def _calculate_rich_text_effects_padding(self, layer: TextLayer) -> int: """Calculate padding for rich text effects from both layer and part-level effects.""" all_stroke_effects: list[Stroke] = []