Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions quickthumb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
BlendMode,
Blinds,
Box,
CanvasInspection,
Checkerboard,
Circle,
Diagnostic,
Expand All @@ -24,6 +25,8 @@
GroupLayer,
ImageEffect,
ImageLayer,
InspectionBBox,
LayerInspection,
LinearGradient,
OutlineLayer,
RadialGradient,
Expand All @@ -34,6 +37,7 @@
SvgLayer,
TextEffect,
TextFillImage,
TextInspection,
TextLayer,
TextPart,
Wheel,
Expand All @@ -60,7 +64,11 @@
"Fade",
"Wheel",
"Wipe",
"CanvasInspection",
"Diagnostic",
"InspectionBBox",
"LayerInspection",
"TextInspection",
"GroupLayer",
"Background",
"BackgroundLayer",
Expand Down
5 changes: 1 addition & 4 deletions quickthumb/_export_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 1 addition & 4 deletions quickthumb/_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 5 additions & 1 deletion quickthumb/_measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
98 changes: 59 additions & 39 deletions quickthumb/_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand All @@ -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)

Expand All @@ -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] = []
Expand Down
63 changes: 62 additions & 1 deletion quickthumb/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,11 +24,15 @@
BackgroundEffect,
BackgroundLayer,
BlendMode,
CanvasInspection,
Diagnostic,
FitMode,
Grain,
GroupLayer,
ImageEffect,
ImageLayer,
InspectionBBox,
LayerInspection,
LayerType,
LinearGradient,
OutlineLayer,
Expand All @@ -36,6 +41,7 @@
ShapeLayer,
SvgLayer,
TextFillImage,
TextInspection,
TextLayer,
TextPart,
)
Expand Down Expand Up @@ -206,14 +212,69 @@ 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)
that an agent or human can act on before rendering.
"""
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)
Expand Down
34 changes: 34 additions & 0 deletions quickthumb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading