diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index cddf5d2..4554b24 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -2,6 +2,7 @@ from itertools import islice from math import ceil from typing import TYPE_CHECKING +from unicodedata import category from PIL import Image, ImageChops @@ -34,7 +35,6 @@ visible_leaf_layers, ) from quickthumb._effects import EffectsEngine -from quickthumb._fonts import FontEngine from quickthumb._groups import GroupEngine from quickthumb._images import ImageEngine from quickthumb._measurements import BBox, LayerMeasurement, measure_layers @@ -69,7 +69,6 @@ def __init__( ctx: RenderContext, canvas: "Canvas", effects: EffectsEngine, - fonts: FontEngine, images: ImageEngine, shapes: ShapeEngine, text: TextEngine, @@ -78,7 +77,6 @@ def __init__( self._ctx = ctx self._canvas = canvas self._effects = effects - self._fonts = fonts self._images = images self._shapes = shapes self._text = text @@ -630,6 +628,48 @@ def _diagnose_text_layer( ) ) + clipping = self._find_text_clipping(measured, has_overflow=overflow is not None) + if clipping is not None: + findings.append( + Diagnostic( + code="text-clipped", + severity="warning", + layer_index=measured.index, + message=( + f"wrapped text block at ({clipping['text_bbox']['x']}, " + f"{clipping['text_bbox']['y']}) size " + f"{clipping['text_bbox']['width']}x{clipping['text_bbox']['height']} " + f"exceeds {clipping['clipped_by']} and may be clipped" + ), + measured=clipping, + suggestion=( + "move the text fully inside the canvas, reduce text size, " + "increase max_width, or enable auto_scale" + ), + **diagnostic_context(measured), + ) + ) + + missing_glyphs = self._find_missing_glyphs(layer) + if missing_glyphs: + display = ", ".join(repr(char) for char in missing_glyphs) + findings.append( + Diagnostic( + code="missing-glyph", + severity="warning", + layer_index=measured.index, + message=( + f"text contains glyphs that render as the font replacement glyph: {display}" + ), + measured={ + "characters": missing_glyphs, + "character_count": len(missing_glyphs), + }, + suggestion=f"use a font that supports {display}", + **diagnostic_context(measured), + ) + ) + contrast = self._text_background_contrast(running, measured) if contrast is not None and contrast < LOW_CONTRAST_THRESHOLD: findings.append( @@ -666,23 +706,8 @@ def _find_overflowing_word(self, layer: TextLayer) -> tuple[str, int, int] | Non max_width_px = parse_coordinate(layer.max_width, self._ctx.width) - if isinstance(layer.content, str): - font = self._fonts.load_font(layer) - return self._first_word_wider_than( - layer.content, font, layer.letter_spacing or 0, max_width_px - ) - - for part in layer.content: - font = self._fonts.load_font_variant( - part.font or layer.font, - self._text.resolve_size(part, layer), - self._text.resolve_bold(part, layer), - self._text.resolve_italic(part, layer), - self._text.resolve_weight(part, layer), - ) - word = self._first_word_wider_than( - part.text, font, self._text.resolve_letter_spacing(part, layer), max_width_px - ) + for text, font, letter_spacing in self._text.iter_text_runs(layer): + word = self._first_word_wider_than(text, font, letter_spacing, max_width_px) if word is not None: return word return None @@ -696,6 +721,101 @@ def _first_word_wider_than( return word, width, max_width_px return None + def _find_text_clipping( + self, measured: LayerMeasurement, *, has_overflow: bool = False + ) -> dict | None: + layer = measured.effective_text_layer + box = measured.bbox + if layer is None or box is None or not layer.max_width: + return None + + wrapped_lines = tuple(measured.metadata.get("wrapped_lines", ())) + if len(wrapped_lines) < 2: + return None + + max_width_px = parse_coordinate(layer.max_width, self._ctx.width) + text_bbox = bbox_payload(box) + base = { + "text_bbox": text_bbox, + "wrapped_line_count": len(wrapped_lines), + "max_width": max_width_px, + "text_width": box.width, + "text_height": box.height, + "canvas_width": self._ctx.width, + "canvas_height": self._ctx.height, + } + + canvas_overflow = self._canvas_overflow(box) + if canvas_overflow: + if has_overflow and "top" not in canvas_overflow and "bottom" not in canvas_overflow: + return None + return { + **base, + "clipped_by": "canvas", + "overflow": canvas_overflow, + } + + if box.width > max_width_px: + return { + **base, + "clipped_by": "max_width", + "overflow_width": box.width - max_width_px, + } + + return None + + def _canvas_overflow(self, box) -> dict[str, int]: + overflow = { + "left": max(0, -box.x), + "top": max(0, -box.y), + "right": max(0, box.right - self._ctx.width), + "bottom": max(0, box.bottom - self._ctx.height), + } + return {edge: amount for edge, amount in overflow.items() if amount > 0} + + def _find_missing_glyphs(self, layer: TextLayer) -> list[str]: + missing: list[str] = [] + reported_missing: set[str] = set() + checked: set[tuple[object, str]] = set() + for text, font, _letter_spacing in self._text.iter_text_runs(layer): + missing_signatures = self._missing_glyph_signatures(font) + for char in text: + if ( + char in reported_missing + or char in {"\u25a1", "\ufffd"} + or char.isspace() + or category(char)[0] == "C" + ): + continue + + checked_key = (font, char) + if checked_key in checked: + continue + checked.add(checked_key) + + signature = self._glyph_signature(font, char) + if signature is not None and signature in missing_signatures: + missing.append(char) + reported_missing.add(char) + return missing + + def _missing_glyph_signatures(self, font) -> set: + return { + signature + for probe in ("\ufffd", "\uffff", "\u0378") + if (signature := self._glyph_signature(font, probe)) is not None + } + + def _glyph_signature(self, font, char: str): + try: + mask = font.getmask(char) + data = bytes(mask) + if not data or not any(data): + return None + return font.getbbox(char), mask.size, data + except (OSError, UnicodeError, ValueError): + return None + def _text_background_contrast( self, running: Image.Image, measured: LayerMeasurement ) -> float | None: diff --git a/quickthumb/_text.py b/quickthumb/_text.py index bbc0a9f..ed7fe08 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -1,5 +1,5 @@ import warnings -from collections.abc import Callable +from collections.abc import Callable, Iterable from typing import TypedDict, cast from PIL import Image, ImageDraw, ImageFilter @@ -173,6 +173,25 @@ def measure_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: return self._measure_rich_text_layout(layer) return self._measure_simple_text_layout(layer) + def iter_text_runs(self, layer: TextLayer) -> Iterable[tuple[str, FontType, int]]: + """Yield text with the font variant and spacing that render it.""" + if isinstance(layer.content, str): + yield layer.content, self._fonts.load_font(layer), layer.letter_spacing or 0 + return + + for part in layer.content: + yield ( + part.text, + self._fonts.load_font_variant( + part.font or layer.font, + self.resolve_size(part, layer), + self.resolve_bold(part, layer), + self.resolve_italic(part, layer), + self.resolve_weight(part, layer), + ), + self.resolve_letter_spacing(part, 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 "" diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index 67a518e..92f9940 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -172,7 +172,6 @@ def __init__( self._ctx, self, self._effects, - self._fonts, self._images, self._shapes, self._text, diff --git a/quickthumb/models.py b/quickthumb/models.py index a95f694..281c837 100644 --- a/quickthumb/models.py +++ b/quickthumb/models.py @@ -821,6 +821,8 @@ class Diagnostic(quickthumbModel): "off-canvas", "tiny-text", "text-overflow", + "text-clipped", + "missing-glyph", "low-contrast", "layer-overlap", "layer-hidden", diff --git a/tests/test_cli.py b/tests/test_cli.py index 4f855a2..93ea0fa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -737,6 +737,197 @@ def test_should_emit_structured_json_for_layer_overlap(self): ], } + def test_should_emit_structured_json_for_text_clipped(self): + """lint --format json includes structured text-clipped diagnostics""" + from quickthumb.cli import app + + # given: a spec with wrapped text that runs beyond the canvas + spec_path = self._write_spec( + { + "width": 260, + "height": 110, + "layers": [ + {"type": "background", "color": "#FFFFFF"}, + { + "type": "text", + "content": "one two three four five six seven eight nine ten", + "size": 30, + "color": "#000000", + "position": [10, 70], + "max_width": 90, + }, + ], + } + ) + + # when + try: + result = CliRunner().invoke(app, ["lint", spec_path, "--format", "json"]) + finally: + os.unlink(spec_path) + + # then + assert result.exit_code == 3 + payload = json.loads(result.output) + assert payload["summary"]["diagnostic_count"] == 2 + finding = payload["diagnostics"][0] + bbox = finding["bbox"] + assert bbox["width"] <= 90 + assert bbox["y"] + bbox["height"] > 110 + assert finding == { + "code": "text-clipped", + "severity": "warning", + "layer_index": 1, + "message": ( + f"wrapped text block at (10, 70) size {bbox['width']}x{bbox['height']} " + "exceeds canvas and may be clipped" + ), + "layer_id": "layer:1", + "bbox": {"x": 10, "y": 70, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": { + "text_bbox": {"x": 10, "y": 70, "width": bbox["width"], "height": bbox["height"]}, + "wrapped_line_count": 10, + "max_width": 90, + "text_width": bbox["width"], + "text_height": bbox["height"], + "canvas_width": 260, + "canvas_height": 110, + "clipped_by": "canvas", + "overflow": {"bottom": bbox["y"] + bbox["height"] - 110}, + }, + "suggestion": ( + "move the text fully inside the canvas, reduce text size, increase max_width, " + "or enable auto_scale" + ), + } + + def test_should_emit_structured_json_for_declared_width_text_clipping(self): + """lint --format json reports wrapped text that exceeds max_width""" + from quickthumb.cli import app + + # given: a wrapped block exceeds max_width but stays within the canvas + spec_path = self._write_spec( + { + "width": 400, + "height": 300, + "layers": [ + {"type": "background", "color": "#FFFFFF"}, + { + "type": "text", + "content": "overlong ok", + "size": 40, + "color": "#000000", + "position": [10, 10], + "max_width": 50, + }, + ], + } + ) + + # when + try: + result = CliRunner().invoke(app, ["lint", spec_path, "--format", "json"]) + finally: + os.unlink(spec_path) + + # then + assert result.exit_code == 3 + payload = json.loads(result.output) + assert payload["summary"] == { + "diagnostic_count": 2, + "error_count": 0, + "warning_count": 2, + } + assert [finding["code"] for finding in payload["diagnostics"]] == [ + "text-overflow", + "text-clipped", + ] + finding = payload["diagnostics"][1] + bbox = finding["bbox"] + assert bbox["width"] > 50 + assert bbox["x"] + bbox["width"] <= 400 + assert finding == { + "code": "text-clipped", + "severity": "warning", + "layer_index": 1, + "message": ( + f"wrapped text block at (10, 10) size {bbox['width']}x{bbox['height']} " + "exceeds max_width and may be clipped" + ), + "layer_id": "layer:1", + "bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": { + "text_bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "wrapped_line_count": 2, + "max_width": 50, + "text_width": bbox["width"], + "text_height": bbox["height"], + "canvas_width": 400, + "canvas_height": 300, + "clipped_by": "max_width", + "overflow_width": bbox["width"] - 50, + }, + "suggestion": ( + "move the text fully inside the canvas, reduce text size, increase max_width, " + "or enable auto_scale" + ), + } + + def test_should_emit_structured_json_for_missing_glyph(self, monkeypatch): + """lint --format json includes structured missing-glyph diagnostics""" + from quickthumb.cli import app + + # given: the bundled default font and a character it renders as tofu + monkeypatch.delenv("QUICKTHUMB_DEFAULT_FONT", raising=False) + spec_path = self._write_spec( + { + "width": 200, + "height": 120, + "layers": [ + {"type": "background", "color": "#FFFFFF"}, + { + "type": "text", + "content": "\ud55c", + "size": 40, + "color": "#000000", + "position": [10, 10], + }, + ], + } + ) + + # when + try: + result = CliRunner().invoke(app, ["lint", spec_path, "--format", "json"]) + finally: + os.unlink(spec_path) + + # then + assert result.exit_code == 3 + payload = json.loads(result.output) + assert payload["summary"] == { + "diagnostic_count": 1, + "error_count": 0, + "warning_count": 1, + } + finding = payload["diagnostics"][0] + bbox = finding["bbox"] + assert finding == { + "code": "missing-glyph", + "severity": "warning", + "layer_index": 1, + "message": ( + "text contains glyphs that render as the font replacement glyph: " + repr("\ud55c") + ), + "layer_id": "layer:1", + "bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": {"characters": ["\ud55c"], "character_count": 1}, + "suggestion": "use a font that supports '\ud55c'", + } + def test_should_emit_structured_json_for_group_child_overlap(self): """lint --format json reports grouped child overlap with stable related layer ids""" from quickthumb.cli import app diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index e34c800..62469d4 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -432,6 +432,265 @@ def test_should_include_structured_values_for_text_overflow(self): ) assert finding.suggestion == expected_suggestion + def test_should_warn_when_wrapped_text_extends_past_canvas(self): + """Wrapped text that runs beyond the canvas receives a text-clipped warning""" + from quickthumb import Canvas + + # given: a wrapped text block starting too low to fit all rendered lines + canvas = ( + Canvas(260, 110) + .background(color="#FFFFFF") + .text( + "one two three four five six seven eight nine ten", + size=30, + color="#000000", + position=(10, 70), + max_width=90, + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [d.code for d in diagnostics] == ["text-clipped", "off-canvas"] + finding = diagnostics[0] + assert finding.bbox is not None + bbox = finding.bbox.model_dump() + assert bbox["width"] <= 90 + assert bbox["y"] + bbox["height"] > 110 + assert finding.model_dump() == { + "code": "text-clipped", + "severity": "warning", + "layer_index": 1, + "message": ( + f"wrapped text block at (10, 70) size {bbox['width']}x{bbox['height']} " + "exceeds canvas and may be clipped" + ), + "layer_id": "layer:1", + "layer_name": None, + "bbox": {"x": 10, "y": 70, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": { + "text_bbox": {"x": 10, "y": 70, "width": bbox["width"], "height": bbox["height"]}, + "wrapped_line_count": 10, + "max_width": 90, + "text_width": bbox["width"], + "text_height": bbox["height"], + "canvas_width": 260, + "canvas_height": 110, + "clipped_by": "canvas", + "overflow": {"bottom": bbox["y"] + bbox["height"] - 110}, + }, + "suggestion": ( + "move the text fully inside the canvas, reduce text size, increase max_width, " + "or enable auto_scale" + ), + } + + def test_should_warn_when_wrapped_text_exceeds_declared_width(self): + """Wrapped text wider than max_width receives a text-clipped warning""" + from quickthumb import Canvas + + # given: a wrapped block with an unbreakable word that exceeds max_width + canvas = ( + Canvas(400, 300) + .background(color="#FFFFFF") + .text( + "overlong ok", + size=40, + color="#000000", + position=(10, 10), + max_width=50, + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [d.code for d in diagnostics] == ["text-overflow", "text-clipped"] + finding = diagnostics[1] + assert finding.bbox is not None + bbox = finding.bbox.model_dump() + assert bbox["width"] > 50 + assert bbox["x"] + bbox["width"] <= 400 + assert finding.model_dump() == { + "code": "text-clipped", + "severity": "warning", + "layer_index": 1, + "message": ( + f"wrapped text block at (10, 10) size {bbox['width']}x{bbox['height']} " + "exceeds max_width and may be clipped" + ), + "layer_id": "layer:1", + "layer_name": None, + "bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": { + "text_bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "wrapped_line_count": 2, + "max_width": 50, + "text_width": bbox["width"], + "text_height": bbox["height"], + "canvas_width": 400, + "canvas_height": 300, + "clipped_by": "max_width", + "overflow_width": bbox["width"] - 50, + }, + "suggestion": ( + "move the text fully inside the canvas, reduce text size, increase max_width, " + "or enable auto_scale" + ), + } + + def test_should_warn_when_overflowing_word_also_extends_past_canvas_vertically(self): + """Text overflow does not suppress vertical text-clipped canvas diagnostics""" + from quickthumb import Canvas + + # given: a wrapped block with an overflowing word that starts too low to fit + canvas = ( + Canvas(400, 300) + .background(color="#FFFFFF") + .text( + "overlong ok", + size=40, + color="#000000", + position=(10, 250), + max_width=50, + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [d.code for d in diagnostics] == ["text-overflow", "text-clipped", "off-canvas"] + finding = diagnostics[1] + assert finding.bbox is not None + bbox = finding.bbox.model_dump() + assert finding.model_dump() == { + "code": "text-clipped", + "severity": "warning", + "layer_index": 1, + "message": ( + f"wrapped text block at (10, 250) size {bbox['width']}x{bbox['height']} " + "exceeds canvas and may be clipped" + ), + "layer_id": "layer:1", + "layer_name": None, + "bbox": {"x": 10, "y": 250, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": { + "text_bbox": {"x": 10, "y": 250, "width": bbox["width"], "height": bbox["height"]}, + "wrapped_line_count": 2, + "max_width": 50, + "text_width": bbox["width"], + "text_height": bbox["height"], + "canvas_width": 400, + "canvas_height": 300, + "clipped_by": "canvas", + "overflow": {"bottom": bbox["y"] + bbox["height"] - 300}, + }, + "suggestion": ( + "move the text fully inside the canvas, reduce text size, increase max_width, " + "or enable auto_scale" + ), + } + + def test_should_warn_when_default_font_renders_missing_glyph(self, monkeypatch): + """Characters rendered as the active font replacement glyph are flagged""" + from quickthumb import Canvas + + # given: the bundled default font and a Hangul character it cannot draw + monkeypatch.delenv("QUICKTHUMB_DEFAULT_FONT", raising=False) + canvas = ( + Canvas(200, 120) + .background(color="#FFFFFF") + .text("\ud55c", size=40, color="#000000", position=(10, 10)) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [d.code for d in diagnostics] == ["missing-glyph"] + finding = diagnostics[0] + assert finding.bbox is not None + bbox = finding.bbox.model_dump() + assert finding.model_dump() == { + "code": "missing-glyph", + "severity": "warning", + "layer_index": 1, + "message": ( + "text contains glyphs that render as the font replacement glyph: " + repr("\ud55c") + ), + "layer_id": "layer:1", + "layer_name": None, + "bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": {"characters": ["\ud55c"], "character_count": 1}, + "suggestion": "use a font that supports '\ud55c'", + } + + def test_should_warn_when_later_rich_text_part_renders_same_character_as_missing_glyph(self): + """Missing-glyph checks evaluate repeated characters across rich-text font runs""" + from quickthumb import Canvas, TextPart + + # given: the first font supports the character, but a later font renders it as tofu + missing = "\u0180" + canvas = ( + Canvas(200, 120) + .background(color="#FFFFFF") + .text( + [ + TextPart(text=missing, font="NotoSans"), + TextPart(text=missing, font="Roboto"), + ], + size=40, + color="#000000", + position=(10, 10), + ) + ) + + # when + diagnostics = canvas.diagnose() + + # then + assert [d.code for d in diagnostics] == ["missing-glyph"] + finding = diagnostics[0] + assert finding.bbox is not None + bbox = finding.bbox.model_dump() + assert finding.model_dump() == { + "code": "missing-glyph", + "severity": "warning", + "layer_index": 1, + "message": ( + "text contains glyphs that render as the font replacement glyph: " + repr(missing) + ), + "layer_id": "layer:1", + "layer_name": None, + "bbox": {"x": 10, "y": 10, "width": bbox["width"], "height": bbox["height"]}, + "related_layers": ["layer:1"], + "measured": {"characters": [missing], "character_count": 1}, + "suggestion": f"use a font that supports {repr(missing)}", + } + + def test_should_not_warn_for_skipped_missing_glyph_sentinels(self, monkeypatch): + """Replacement glyph sentinels and whitespace are not reported as missing glyphs""" + from quickthumb import Canvas + + # given: sentinel characters are intentional fallback markers, not missing content + monkeypatch.delenv("QUICKTHUMB_DEFAULT_FONT", raising=False) + canvas = ( + Canvas(200, 120) + .background(color="#FFFFFF") + .text("\ufffd \u25a1\n\tA", size=40, color="#000000", position=(10, 10)) + ) + + # when / then + assert canvas.diagnose() == [] + def test_should_warn_for_low_contrast_text(self): """Near-white text on a white background is flagged as low contrast""" from quickthumb import Canvas