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/_export_base.py b/quickthumb/_export_base.py index d149bc1..7f37a32 100644 --- a/quickthumb/_export_base.py +++ b/quickthumb/_export_base.py @@ -402,13 +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: - 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) 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..9010aaa 100644 --- a/quickthumb/_groups.py +++ b/quickthumb/_groups.py @@ -175,10 +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) - font = self._fonts.load_font(child) - return self._text.measure_simple_text_size(child, font, child.content) + 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/_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 0807fa9..4c85eae 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -56,6 +56,13 @@ class TextPartMetadata(TypedDict): width: int +class TextLayoutMetadata(TypedDict): + size: tuple[int, int] + wrapped_lines: tuple[str, ...] + effective_font_size: int + effective_font_sizes: tuple[int, ...] + + class TextEngine: """Text layout, measurement, wrapping, effects, and rendering.""" @@ -160,6 +167,53 @@ 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) -> 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) -> TextLayoutMetadata: + 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] + 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,), + } + + 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})) + 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,), + } + def _render_simple_text(self, image: Image.Image, layer: TextLayer): # Apply auto-scaling if enabled if layer.auto_scale and layer.max_width: @@ -931,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_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) @@ -954,28 +1005,9 @@ 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]: - """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, - ) + 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): """Render rich text with rotation applied. @@ -987,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) @@ -1003,18 +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.""" - 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 - 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] = [] diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index 7b35ec4..d244d1a 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._measurements import LayerMeasurement, measure_layers from quickthumb._shapes import ShapeEngine from quickthumb._text import TextEngine from quickthumb.errors import RenderingError, ValidationError @@ -23,11 +24,15 @@ BackgroundEffect, BackgroundLayer, BlendMode, + CanvasInspection, + Diagnostic, FitMode, Grain, GroupLayer, ImageEffect, ImageLayer, + InspectionBBox, + LayerInspection, LayerType, LinearGradient, OutlineLayer, @@ -36,6 +41,7 @@ ShapeLayer, SvgLayer, TextFillImage, + TextInspection, TextLayer, TextPart, ) @@ -206,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) @@ -214,6 +220,61 @@ def diagnose(self) -> list: """ return self._diagnostics.diagnose() + def inspect(self) -> CanvasInspection: + """Return a deterministic layout report for this canvas without rendering output.""" + 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, index: int | None = None, order: int | None = None + ) -> LayerInspection: + box = measured.bbox + return LayerInspection( + id=measured.layer_id, + 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=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, index=child_index, order=child_index) + for child_index, child in enumerate(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 + + 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 + return TextInspection( + 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)), + ) + @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..bf1fb2d --- /dev/null +++ b/tests/test_inspection.py @@ -0,0 +1,270 @@ +"""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, CanvasInspection, InspectionBBox, LayerInspection + + # 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 == 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, TextInspection + + # 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 == 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, TextInspection + + # 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 == 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, TextInspection + + # 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 == TextInspection( + wrapped_lines=["ALPHA BETA"], + effective_font_size=17, + effective_font_sizes=[17, 35], + max_width=180, + auto_scaled=True, + ) + + def test_should_report_image_and_svg_bounds(self, tmp_path): + """Image and SVG reports expose measured final bboxes.""" + from quickthumb import Canvas, InspectionBBox, LayerInspection + + # 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 == 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, InspectionBBox, LayerInspection, TextInspection + + # 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 == 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, LayerInspection + + # given: a named custom layer + canvas = Canvas(100, 100).custom(lambda _image: None, name="noop") + + # when + layer = canvas.inspect().layers[0] + + # then + 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, InspectionBBox, LayerInspection + + # 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 == 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), + )