From 9503790c8c53c3bb34b3372e28bf8d98cc79eb4a Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:02:25 +0900 Subject: [PATCH 1/6] feat: add text reliability diagnostics --- quickthumb/_diagnostics.py | 145 +++++++++++++++++++++++++++++++++++++ quickthumb/models.py | 2 + tests/test_cli.py | 101 ++++++++++++++++++++++++++ tests/test_diagnostics.py | 71 ++++++++++++++++++ 4 files changed, 319 insertions(+) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index cddf5d2..233df56 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 @@ -630,6 +631,48 @@ def _diagnose_text_layer( ) ) + clipping = None if overflow is not None else self._find_text_clipping(measured) + 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( @@ -696,6 +739,108 @@ def _first_word_wider_than( return word, width, max_width_px return None + def _find_text_clipping(self, measured: LayerMeasurement) -> 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, + } + + if box.width > max_width_px: + return { + **base, + "clipped_by": "max_width", + "overflow_width": box.width - max_width_px, + } + + canvas_overflow = self._canvas_overflow(box) + if canvas_overflow: + return { + **base, + "clipped_by": "canvas", + "overflow": canvas_overflow, + } + + 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] = [] + seen: set[str] = set() + for text, font in self._text_font_runs(layer): + for char in text: + if char in seen or not self._should_check_glyph(char): + continue + seen.add(char) + if self._renders_as_missing_glyph(font, char): + missing.append(char) + return missing + + def _text_font_runs(self, layer: TextLayer): + if isinstance(layer.content, str): + yield layer.content, self._fonts.load_font(layer) + return + + for part in layer.content: + yield ( + part.text, + 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), + ), + ) + + def _should_check_glyph(self, char: str) -> bool: + if char in {"\u25a1", "\ufffd"}: + return False + return not char.isspace() and category(char)[0] != "C" + + def _renders_as_missing_glyph(self, font, char: str) -> bool: + signature = self._glyph_signature(font, char) + if signature is None: + return False + + for probe in ("\ufffd", "\uffff", "\u0378"): + if char != probe and signature == self._glyph_signature(font, probe): + return True + return False + + 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/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..3f2fffa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -737,6 +737,107 @@ 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] + assert finding["code"] == "text-clipped" + assert finding["severity"] == "warning" + assert finding["layer_index"] == 1 + assert finding["layer_id"] == "layer:1" + assert finding["bbox"]["x"] == 10 + assert finding["bbox"]["y"] == 70 + assert finding["bbox"]["width"] <= 90 + assert finding["bbox"]["y"] + finding["bbox"]["height"] > 110 + assert finding["related_layers"] == ["layer:1"] + assert finding["measured"]["text_bbox"] == finding["bbox"] + assert finding["measured"]["wrapped_line_count"] > 1 + assert finding["measured"]["max_width"] == 90 + assert finding["measured"]["clipped_by"] == "canvas" + assert finding["measured"]["overflow"] == { + "bottom": finding["bbox"]["y"] + finding["bbox"]["height"] - 110 + } + assert finding["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] + assert finding["code"] == "missing-glyph" + assert finding["severity"] == "warning" + assert finding["layer_index"] == 1 + assert finding["layer_id"] == "layer:1" + assert finding["related_layers"] == ["layer:1"] + assert finding["measured"] == {"characters": ["\ud55c"], "character_count": 1} + assert finding["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..4df1269 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -432,6 +432,77 @@ 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.severity == "warning" + assert finding.layer_id == "layer:1" + assert finding.bbox is not None + assert finding.bbox.x == 10 + assert finding.bbox.y == 70 + assert finding.bbox.width <= 90 + assert finding.bbox.y + finding.bbox.height > 110 + assert finding.related_layers == ["layer:1"] + assert finding.measured["text_bbox"] == finding.bbox.model_dump() + assert finding.measured["wrapped_line_count"] > 1 + assert finding.measured["max_width"] == 90 + assert finding.measured["text_width"] == finding.bbox.width + assert finding.measured["text_height"] == finding.bbox.height + assert finding.measured["canvas_width"] == 260 + assert finding.measured["canvas_height"] == 110 + assert finding.measured["clipped_by"] == "canvas" + assert finding.measured["overflow"] == { + "bottom": finding.bbox.y + finding.bbox.height - 110 + } + assert finding.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.severity == "warning" + assert finding.layer_id == "layer:1" + assert finding.bbox is not None + assert finding.measured == {"characters": ["\ud55c"], "character_count": 1} + assert finding.suggestion == "use a font that supports '\ud55c'" + 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 From 59aafc29c60dd9770828f33bee65905743c27ebc Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:23:34 +0900 Subject: [PATCH 2/6] fix: refine text reliability diagnostics --- quickthumb/_diagnostics.py | 69 +++++++++++++++------------------ quickthumb/_text.py | 20 +++++++++- tests/test_cli.py | 52 +++++++++++++++++++++++++ tests/test_diagnostics.py | 78 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 40 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index 233df56..5757c16 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -631,7 +631,7 @@ def _diagnose_text_layer( ) ) - clipping = None if overflow is not None else self._find_text_clipping(measured) + clipping = self._find_text_clipping(measured, has_overflow=overflow is not None) if clipping is not None: findings.append( Diagnostic( @@ -739,7 +739,9 @@ def _first_word_wider_than( return word, width, max_width_px return None - def _find_text_clipping(self, measured: LayerMeasurement) -> dict | 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: @@ -761,21 +763,23 @@ def _find_text_clipping(self, measured: LayerMeasurement) -> dict | None: "canvas_height": self._ctx.height, } - if box.width > max_width_px: - return { - **base, - "clipped_by": "max_width", - "overflow_width": box.width - max_width_px, - } - 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]: @@ -790,46 +794,33 @@ def _canvas_overflow(self, box) -> dict[str, int]: def _find_missing_glyphs(self, layer: TextLayer) -> list[str]: missing: list[str] = [] seen: set[str] = set() - for text, font in self._text_font_runs(layer): + for text, font in self._text.iter_font_runs(layer): + missing_signatures = self._missing_glyph_signatures(font) for char in text: - if char in seen or not self._should_check_glyph(char): + if ( + char in seen + or char in {"\u25a1", "\ufffd"} + or char.isspace() + or category(char)[0] == "C" + ): continue seen.add(char) - if self._renders_as_missing_glyph(font, char): + if self._renders_as_missing_glyph(font, char, missing_signatures): missing.append(char) return missing - def _text_font_runs(self, layer: TextLayer): - if isinstance(layer.content, str): - yield layer.content, self._fonts.load_font(layer) - return - - for part in layer.content: - yield ( - part.text, - 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), - ), - ) - - def _should_check_glyph(self, char: str) -> bool: - if char in {"\u25a1", "\ufffd"}: - return False - return not char.isspace() and category(char)[0] != "C" + 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 _renders_as_missing_glyph(self, font, char: str) -> bool: + def _renders_as_missing_glyph(self, font, char: str, missing_signatures: set) -> bool: signature = self._glyph_signature(font, char) if signature is None: return False - - for probe in ("\ufffd", "\uffff", "\u0378"): - if char != probe and signature == self._glyph_signature(font, probe): - return True - return False + return signature in missing_signatures def _glyph_signature(self, font, char: str): try: diff --git a/quickthumb/_text.py b/quickthumb/_text.py index bbc0a9f..6d2dc88 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,24 @@ def measure_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: return self._measure_rich_text_layout(layer) return self._measure_simple_text_layout(layer) + def iter_font_runs(self, layer: TextLayer) -> Iterable[tuple[str, FontType]]: + """Yield text with the font variant that renders it.""" + if isinstance(layer.content, str): + yield layer.content, self._fonts.load_font(layer) + 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), + ), + ) + 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/tests/test_cli.py b/tests/test_cli.py index 3f2fffa..f2664a2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -792,6 +792,58 @@ def test_should_emit_structured_json_for_text_clipped(self): "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] + assert finding["layer_id"] == "layer:1" + assert finding["bbox"]["x"] == 10 + assert finding["bbox"]["width"] > 50 + assert finding["bbox"]["x"] + finding["bbox"]["width"] <= 400 + assert finding["related_layers"] == ["layer:1"] + assert finding["measured"]["text_bbox"] == finding["bbox"] + assert finding["measured"]["clipped_by"] == "max_width" + assert finding["measured"]["max_width"] == 50 + assert finding["measured"]["overflow_width"] == finding["bbox"]["width"] - 50 + def test_should_emit_structured_json_for_missing_glyph(self, monkeypatch): """lint --format json includes structured missing-glyph diagnostics""" from quickthumb.cli import app diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 4df1269..4aacb32 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -479,6 +479,69 @@ def test_should_warn_when_wrapped_text_extends_past_canvas(self): "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.severity == "warning" + assert finding.layer_id == "layer:1" + assert finding.bbox is not None + assert finding.bbox.x == 10 + assert finding.bbox.width > 50 + assert finding.bbox.x + finding.bbox.width <= 400 + assert finding.measured["text_bbox"] == finding.bbox.model_dump() + assert finding.measured["clipped_by"] == "max_width" + assert finding.measured["max_width"] == 50 + assert finding.measured["overflow_width"] == finding.bbox.width - 50 + + 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.measured["clipped_by"] == "canvas" + assert finding.bbox is not None + assert finding.measured["overflow"] == { + "bottom": finding.bbox.y + finding.bbox.height - 300 + } + 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 @@ -503,6 +566,21 @@ def test_should_warn_when_default_font_renders_missing_glyph(self, monkeypatch): assert finding.measured == {"characters": ["\ud55c"], "character_count": 1} assert finding.suggestion == "use a font that supports '\ud55c'" + 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 From 9b55e48b4024d1e055be599bc3af20d03f2d1522 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:27:48 +0900 Subject: [PATCH 3/6] refactor: simplify text diagnostics flow --- quickthumb/_diagnostics.py | 33 +++++---------------------------- quickthumb/_text.py | 7 ++++--- quickthumb/canvas.py | 1 - 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index 5757c16..c2287cd 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -35,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 @@ -70,7 +69,6 @@ def __init__( ctx: RenderContext, canvas: "Canvas", effects: EffectsEngine, - fonts: FontEngine, images: ImageEngine, shapes: ShapeEngine, text: TextEngine, @@ -79,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 @@ -709,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 @@ -794,7 +776,7 @@ def _canvas_overflow(self, box) -> dict[str, int]: def _find_missing_glyphs(self, layer: TextLayer) -> list[str]: missing: list[str] = [] seen: set[str] = set() - for text, font in self._text.iter_font_runs(layer): + for text, font, _letter_spacing in self._text.iter_text_runs(layer): missing_signatures = self._missing_glyph_signatures(font) for char in text: if ( @@ -805,7 +787,8 @@ def _find_missing_glyphs(self, layer: TextLayer) -> list[str]: ): continue seen.add(char) - if self._renders_as_missing_glyph(font, char, missing_signatures): + signature = self._glyph_signature(font, char) + if signature is not None and signature in missing_signatures: missing.append(char) return missing @@ -816,12 +799,6 @@ def _missing_glyph_signatures(self, font) -> set: if (signature := self._glyph_signature(font, probe)) is not None } - def _renders_as_missing_glyph(self, font, char: str, missing_signatures: set) -> bool: - signature = self._glyph_signature(font, char) - if signature is None: - return False - return signature in missing_signatures - def _glyph_signature(self, font, char: str): try: mask = font.getmask(char) diff --git a/quickthumb/_text.py b/quickthumb/_text.py index 6d2dc88..ed7fe08 100644 --- a/quickthumb/_text.py +++ b/quickthumb/_text.py @@ -173,10 +173,10 @@ def measure_text_layout(self, layer: TextLayer) -> TextLayoutMetadata: return self._measure_rich_text_layout(layer) return self._measure_simple_text_layout(layer) - def iter_font_runs(self, layer: TextLayer) -> Iterable[tuple[str, FontType]]: - """Yield text with the font variant that renders it.""" + 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) + yield layer.content, self._fonts.load_font(layer), layer.letter_spacing or 0 return for part in layer.content: @@ -189,6 +189,7 @@ def iter_font_runs(self, layer: TextLayer) -> Iterable[tuple[str, FontType]]: 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: 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, From d610ce6d5154620374a999b7e72d3408b146a106 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:34:33 +0900 Subject: [PATCH 4/6] test: assert text clipping payloads directly --- tests/test_cli.py | 15 +++++++++++---- tests/test_diagnostics.py | 28 +++++++++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index f2664a2..520bd67 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -839,10 +839,17 @@ def test_should_emit_structured_json_for_declared_width_text_clipping(self): assert finding["bbox"]["width"] > 50 assert finding["bbox"]["x"] + finding["bbox"]["width"] <= 400 assert finding["related_layers"] == ["layer:1"] - assert finding["measured"]["text_bbox"] == finding["bbox"] - assert finding["measured"]["clipped_by"] == "max_width" - assert finding["measured"]["max_width"] == 50 - assert finding["measured"]["overflow_width"] == finding["bbox"]["width"] - 50 + assert finding["measured"] == { + "text_bbox": finding["bbox"], + "wrapped_line_count": 2, + "max_width": 50, + "text_width": finding["bbox"]["width"], + "text_height": finding["bbox"]["height"], + "canvas_width": 400, + "canvas_height": 300, + "clipped_by": "max_width", + "overflow_width": finding["bbox"]["width"] - 50, + } def test_should_emit_structured_json_for_missing_glyph(self, monkeypatch): """lint --format json includes structured missing-glyph diagnostics""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 4aacb32..50e4eaf 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -508,10 +508,17 @@ def test_should_warn_when_wrapped_text_exceeds_declared_width(self): assert finding.bbox.x == 10 assert finding.bbox.width > 50 assert finding.bbox.x + finding.bbox.width <= 400 - assert finding.measured["text_bbox"] == finding.bbox.model_dump() - assert finding.measured["clipped_by"] == "max_width" - assert finding.measured["max_width"] == 50 - assert finding.measured["overflow_width"] == finding.bbox.width - 50 + assert finding.measured == { + "text_bbox": finding.bbox.model_dump(), + "wrapped_line_count": 2, + "max_width": 50, + "text_width": finding.bbox.width, + "text_height": finding.bbox.height, + "canvas_width": 400, + "canvas_height": 300, + "clipped_by": "max_width", + "overflow_width": finding.bbox.width - 50, + } def test_should_warn_when_overflowing_word_also_extends_past_canvas_vertically(self): """Text overflow does not suppress vertical text-clipped canvas diagnostics""" @@ -536,10 +543,17 @@ def test_should_warn_when_overflowing_word_also_extends_past_canvas_vertically(s # then assert [d.code for d in diagnostics] == ["text-overflow", "text-clipped", "off-canvas"] finding = diagnostics[1] - assert finding.measured["clipped_by"] == "canvas" assert finding.bbox is not None - assert finding.measured["overflow"] == { - "bottom": finding.bbox.y + finding.bbox.height - 300 + assert finding.measured == { + "text_bbox": finding.bbox.model_dump(), + "wrapped_line_count": 2, + "max_width": 50, + "text_width": finding.bbox.width, + "text_height": finding.bbox.height, + "canvas_width": 400, + "canvas_height": 300, + "clipped_by": "canvas", + "overflow": {"bottom": finding.bbox.y + finding.bbox.height - 300}, } def test_should_warn_when_default_font_renders_missing_glyph(self, monkeypatch): From 91beec8a599aeec8425817ae993ff4cff68e1159 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:49:40 +0900 Subject: [PATCH 5/6] test: compare text reliability diagnostics directly --- tests/test_cli.py | 113 ++++++++++++++++++---------- tests/test_diagnostics.py | 153 +++++++++++++++++++++++++------------- 2 files changed, 175 insertions(+), 91 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 520bd67..93ea0fa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -771,26 +771,36 @@ def test_should_emit_structured_json_for_text_clipped(self): payload = json.loads(result.output) assert payload["summary"]["diagnostic_count"] == 2 finding = payload["diagnostics"][0] - assert finding["code"] == "text-clipped" - assert finding["severity"] == "warning" - assert finding["layer_index"] == 1 - assert finding["layer_id"] == "layer:1" - assert finding["bbox"]["x"] == 10 - assert finding["bbox"]["y"] == 70 - assert finding["bbox"]["width"] <= 90 - assert finding["bbox"]["y"] + finding["bbox"]["height"] > 110 - assert finding["related_layers"] == ["layer:1"] - assert finding["measured"]["text_bbox"] == finding["bbox"] - assert finding["measured"]["wrapped_line_count"] > 1 - assert finding["measured"]["max_width"] == 90 - assert finding["measured"]["clipped_by"] == "canvas" - assert finding["measured"]["overflow"] == { - "bottom": finding["bbox"]["y"] + finding["bbox"]["height"] - 110 + 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" + ), } - assert finding["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""" @@ -834,21 +844,35 @@ def test_should_emit_structured_json_for_declared_width_text_clipping(self): "text-clipped", ] finding = payload["diagnostics"][1] - assert finding["layer_id"] == "layer:1" - assert finding["bbox"]["x"] == 10 - assert finding["bbox"]["width"] > 50 - assert finding["bbox"]["x"] + finding["bbox"]["width"] <= 400 - assert finding["related_layers"] == ["layer:1"] - assert finding["measured"] == { - "text_bbox": finding["bbox"], - "wrapped_line_count": 2, - "max_width": 50, - "text_width": finding["bbox"]["width"], - "text_height": finding["bbox"]["height"], - "canvas_width": 400, - "canvas_height": 300, - "clipped_by": "max_width", - "overflow_width": finding["bbox"]["width"] - 50, + 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): @@ -889,13 +913,20 @@ def test_should_emit_structured_json_for_missing_glyph(self, monkeypatch): "warning_count": 1, } finding = payload["diagnostics"][0] - assert finding["code"] == "missing-glyph" - assert finding["severity"] == "warning" - assert finding["layer_index"] == 1 - assert finding["layer_id"] == "layer:1" - assert finding["related_layers"] == ["layer:1"] - assert finding["measured"] == {"characters": ["\ud55c"], "character_count": 1} - assert finding["suggestion"] == "use a font that supports '\ud55c'" + 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""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 50e4eaf..3b589c4 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -455,29 +455,38 @@ def test_should_warn_when_wrapped_text_extends_past_canvas(self): # then assert [d.code for d in diagnostics] == ["text-clipped", "off-canvas"] finding = diagnostics[0] - assert finding.severity == "warning" - assert finding.layer_id == "layer:1" assert finding.bbox is not None - assert finding.bbox.x == 10 - assert finding.bbox.y == 70 - assert finding.bbox.width <= 90 - assert finding.bbox.y + finding.bbox.height > 110 - assert finding.related_layers == ["layer:1"] - assert finding.measured["text_bbox"] == finding.bbox.model_dump() - assert finding.measured["wrapped_line_count"] > 1 - assert finding.measured["max_width"] == 90 - assert finding.measured["text_width"] == finding.bbox.width - assert finding.measured["text_height"] == finding.bbox.height - assert finding.measured["canvas_width"] == 260 - assert finding.measured["canvas_height"] == 110 - assert finding.measured["clipped_by"] == "canvas" - assert finding.measured["overflow"] == { - "bottom": finding.bbox.y + finding.bbox.height - 110 + 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" + ), } - assert finding.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""" @@ -502,22 +511,37 @@ def test_should_warn_when_wrapped_text_exceeds_declared_width(self): # then assert [d.code for d in diagnostics] == ["text-overflow", "text-clipped"] finding = diagnostics[1] - assert finding.severity == "warning" - assert finding.layer_id == "layer:1" assert finding.bbox is not None - assert finding.bbox.x == 10 - assert finding.bbox.width > 50 - assert finding.bbox.x + finding.bbox.width <= 400 - assert finding.measured == { - "text_bbox": finding.bbox.model_dump(), - "wrapped_line_count": 2, - "max_width": 50, - "text_width": finding.bbox.width, - "text_height": finding.bbox.height, - "canvas_width": 400, - "canvas_height": 300, - "clipped_by": "max_width", - "overflow_width": finding.bbox.width - 50, + 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): @@ -544,16 +568,34 @@ def test_should_warn_when_overflowing_word_also_extends_past_canvas_vertically(s assert [d.code for d in diagnostics] == ["text-overflow", "text-clipped", "off-canvas"] finding = diagnostics[1] assert finding.bbox is not None - assert finding.measured == { - "text_bbox": finding.bbox.model_dump(), - "wrapped_line_count": 2, - "max_width": 50, - "text_width": finding.bbox.width, - "text_height": finding.bbox.height, - "canvas_width": 400, - "canvas_height": 300, - "clipped_by": "canvas", - "overflow": {"bottom": finding.bbox.y + finding.bbox.height - 300}, + 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): @@ -574,11 +616,22 @@ def test_should_warn_when_default_font_renders_missing_glyph(self, monkeypatch): # then assert [d.code for d in diagnostics] == ["missing-glyph"] finding = diagnostics[0] - assert finding.severity == "warning" - assert finding.layer_id == "layer:1" assert finding.bbox is not None - assert finding.measured == {"characters": ["\ud55c"], "character_count": 1} - assert finding.suggestion == "use a font that supports '\ud55c'" + 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_not_warn_for_skipped_missing_glyph_sentinels(self, monkeypatch): """Replacement glyph sentinels and whitespace are not reported as missing glyphs""" From c316625b361a71c71c7c80a334f4327def7bc9c3 Mon Sep 17 00:00:00 2001 From: sjquant Date: Sun, 5 Jul 2026 17:59:02 +0900 Subject: [PATCH 6/6] fix: check missing glyphs per font run --- quickthumb/_diagnostics.py | 13 +++++++++--- tests/test_diagnostics.py | 43 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/quickthumb/_diagnostics.py b/quickthumb/_diagnostics.py index c2287cd..4554b24 100644 --- a/quickthumb/_diagnostics.py +++ b/quickthumb/_diagnostics.py @@ -775,21 +775,28 @@ def _canvas_overflow(self, box) -> dict[str, int]: def _find_missing_glyphs(self, layer: TextLayer) -> list[str]: missing: list[str] = [] - seen: set[str] = set() + 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 seen + char in reported_missing or char in {"\u25a1", "\ufffd"} or char.isspace() or category(char)[0] == "C" ): continue - seen.add(char) + + 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: diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 3b589c4..62469d4 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -633,6 +633,49 @@ def test_should_warn_when_default_font_renders_missing_glyph(self, monkeypatch): "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