diff --git a/src/adaptive_images.py b/src/adaptive_images.py index a3e14ade..72c0907a 100644 --- a/src/adaptive_images.py +++ b/src/adaptive_images.py @@ -136,7 +136,15 @@ def fit_image(img: Image.Image, box: Any, *, mode: str = "contain", scale = min(scale, 1.0) out_w = max(1, round(src_w * scale)) out_h = max(1, round(src_h * scale)) - out = work if (out_w, out_h) == (src_w, src_h) else work.resize((out_w, out_h), resample) + if (out_w, out_h) == (src_w, src_h): + # No resize needed — but `work` may still BE the caller's original + # image (RGBA source, no ink crop). The result must always be an + # independent copy: LayoutContext caches ImageFitResults, and an + # aliased image would let later mutations of the source corrupt + # cached fits (or vice versa). + out = work.copy() if work is img else work + else: + out = work.resize((out_w, out_h), resample) return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h)) diff --git a/src/adaptive_layout.py b/src/adaptive_layout.py index 7837e0f9..df870052 100644 --- a/src/adaptive_layout.py +++ b/src/adaptive_layout.py @@ -328,13 +328,28 @@ class LayoutContext: # fonts, which step between crisp ladder rungs instead. self.scale = min(self.width / max(1, design_w), self.height / max(1, design_h)) - self._fit_cache: Dict[Any, FitResult] = {} - # LRU-bounded (images are big, unlike text fits). Entries hold a - # strong reference to the source image when keyed by id() so the id - # can't be recycled out from under the cache. + # LRU-bounded: entries are small, but keys embed the fitted TEXT — + # a plugin fitting changing text (a live game clock, a ticker) on a + # 24/7 service would otherwise grow this without bound. + self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict() + # LRU-bounded (images are big). Entries hold a strong reference to + # the source image when keyed by id() so the id can't be recycled + # out from under the cache. self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict() _IMAGE_CACHE_MAX = 64 + _FIT_CACHE_MAX = 512 + + def _fit_cache_get(self, key: Any) -> Optional["FitResult"]: + cached = self._fit_cache.get(key) + if cached is not None: + self._fit_cache.move_to_end(key) + return cached + + def _fit_cache_put(self, key: Any, result: "FitResult") -> None: + self._fit_cache[key] = result + while len(self._fit_cache) > self._FIT_CACHE_MAX: + self._fit_cache.popitem(last=False) # ---- the three adaptation patterns -------------------------------- @@ -373,11 +388,11 @@ class LayoutContext: acceptable rendering exists.""" box_w, box_h = _box_dims(box) key = ("text", text, box_w, box_h, ladder, ellipsis) - cached = self._fit_cache.get(key) + cached = self._fit_cache_get(key) if cached is not None: return cached result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis) - self._fit_cache[key] = result + self._fit_cache_put(key, result) return result def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]], @@ -416,14 +431,14 @@ class LayoutContext: box_w, box_h = _box_dims(box) effective_scale = self.scale if scale is None else scale key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale) - cached = self._fit_cache.get(key) + cached = self._fit_cache_get(key) if cached is not None: return cached target = base_size_px * effective_scale eligible = [step for step in ladder if step.size_px <= target] candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),) result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis) - self._fit_cache[key] = result + self._fit_cache_put(key, result) return result def _walk_ladder(self, text: str, ladder: Sequence[FontStep], @@ -460,7 +475,7 @@ class LayoutContext: one wouldn't (baseball's multiline pattern). Text is the widest line.""" box_w, box_h = _box_dims(box) key = ("lines", tuple(lines), box_w, box_h, ladder, spacing) - cached = self._fit_cache.get(key) + cached = self._fit_cache_get(key) if cached is not None: return cached @@ -482,7 +497,7 @@ class LayoutContext: if result.fits: break - self._fit_cache[key] = result + self._fit_cache_put(key, result) return result def font_for_rows(self, rows: int, box_h: int, @@ -491,7 +506,7 @@ class LayoutContext: (baseball's traditional-scoreboard pattern). Measures a digit/cap sample rather than specific strings.""" key = ("rows", rows, box_h, ladder) - cached = self._fit_cache.get(key) + cached = self._fit_cache_get(key) if cached is not None: return cached @@ -508,7 +523,7 @@ class LayoutContext: if result.fits: break - self._fit_cache[key] = result + self._fit_cache_put(key, result) return result # ---- images --------------------------------------------------------- diff --git a/test/test_adaptive_images.py b/test/test_adaptive_images.py index 4ccd4889..5bf71c44 100644 --- a/test/test_adaptive_images.py +++ b/test/test_adaptive_images.py @@ -194,3 +194,15 @@ class TestBasePluginDrawImage: assert ifit.height == 32 # pasted onto the mock's canvas assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0) + + +class TestResultIndependence: + def test_same_size_fit_never_aliases_the_source(self): + """LayoutContext caches ImageFitResults — an aliased image would let + later mutations of the source corrupt cached fits (or vice versa).""" + from PIL import ImageDraw + src = Image.new("RGBA", (20, 20), (255, 0, 0, 255)) + fit = fit_image(src, (20, 20)) + assert fit.image is not src + ImageDraw.Draw(src).rectangle([0, 0, 19, 19], fill=(0, 255, 0, 255)) + assert fit.image.getpixel((5, 5)) == (255, 0, 0, 255) diff --git a/test/test_adaptive_layout.py b/test/test_adaptive_layout.py index 678c0be0..dd40aa90 100644 --- a/test/test_adaptive_layout.py +++ b/test/test_adaptive_layout.py @@ -436,3 +436,19 @@ class TestBasePluginIntegration: MockCacheManager(), pm) assert plugin.layout.design_size == (64, 32) assert plugin.layout.scale == 2.0 + + +class TestFitCacheBound: + def test_fit_cache_is_lru_bounded(self, ctx): + """A plugin fitting changing text (live game clock, ticker) on a + 24/7 service must not grow the fit cache without bound.""" + for i in range(ctx._FIT_CACHE_MAX + 100): + ctx.fit_text(f"tick {i}", Region(0, 0, 100, 20)) + assert len(ctx._fit_cache) <= ctx._FIT_CACHE_MAX + + def test_lru_keeps_recent_entries_hot(self, ctx): + hot = ctx.fit_text("stay hot", Region(0, 0, 100, 20)) + for i in range(ctx._FIT_CACHE_MAX - 1): + ctx.fit_text(f"cold {i}", Region(0, 0, 100, 20)) + ctx.fit_text("stay hot", Region(0, 0, 100, 20)) # keep touching it + assert ctx.fit_text("stay hot", Region(0, 0, 100, 20)) is hot