diff --git a/amateurfunk_anki.py b/amateurfunk_anki.py index 01d1007..215638a 100644 --- a/amateurfunk_anki.py +++ b/amateurfunk_anki.py @@ -165,6 +165,23 @@ TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse" # inside the package as a deck tree. TOPIC_SPLIT_CLASSES = frozenset({"E", "A"}) +# The catalog's figures come in two kinds: vector SVGs (the bulk; mostly +# small — well under ~200 px) and a handful of raster PNGs (all 1024×685). We +# enlarge only the SVGs: they scale crisply, they're the ones that read tiny +# on a wide desktop card, and the PNGs are already at roughly the size we're +# aiming for. The enlargement is `MEDIA_SCALE`× the native size, with +# `MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` acting as an *enlargement ceiling* — +# it bounds how far a below-cap figure may grow (a figure already at/above a +# cap is left at native size; we never shrink). The ceiling is the PNGs' +# ballpark (1024×768) so the largest SVG schematics (up to 635×481 px in the +# 3rd edition: the NG20x block diagrams, EB309/EB310) grow to a comparable +# size rather than staying cramped, while folding in native *height* still +# stops tall-but-narrow figures from growing off the bottom of the card. +# See `MediaRegistry.image_html`. +MEDIA_SCALE = 2 +MEDIA_MAX_WIDTH = 1024 +MEDIA_MAX_HEIGHT = 768 + # Fallback build epoch if neither the manifest nor `--epoch` supplies # one. Picked as 0 so missing-metadata builds are still deterministic # and obviously wrong (timestamps would all show 1970). @@ -837,6 +854,8 @@ class MediaRegistry: self.used_paths = set() # references that didn't resolve (recorded for diagnostics) self.missing = [] + # Path → intrinsic (w, h) in px (memoised; None when unreadable) + self._size_cache = {} self._index_media_dir() def _index_media_dir(self): @@ -878,12 +897,43 @@ class MediaRegistry: return None def image_html(self, reference): - """Return an `` tag for `reference`, or `""` if unresolved.""" + """Return an `` tag for `reference`, or `""` if unresolved. + + Most catalog SVGs carry small intrinsic dimensions, so on a wide + desktop card they render barely legible (a small fixed-px image + occupies only a sliver of the column; `max-width: 100%` only + ever shrinks, never enlarges). We raise the *preferred* width + via an explicit `width` attribute so the figure grows on every + viewport until the card's `max-width: 100%` cap engages. + + Only SVGs are scaled — the catalog's PNGs are already large + (1024×685), so `_intrinsic_svg_size` returns `None` for them and + they fall through to native size. The enlargement factor is + `scale_for_size` — `MEDIA_SCALE`×, clamped at the + `MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` enlargement ceiling. + `height: auto` in the CSS keeps the aspect ratio. When the size + can't be read (non-SVG, or an unparseable SVG) we fall back to + native. + """ path = self.resolve(reference) if path is None: return "" filename = html.escape(path.name, quote=True) - return f'' + size = self._intrinsic_svg_size(path) + if size is None: + return f'' + width, height = size + scale = scale_for_size(width, height) + if scale <= 1.0: + return f'' + scaled = round(width * scale) + return f'' + + def _intrinsic_svg_size(self, path): + """Return the SVG's `(width, height)` in px (memoised; SVG only).""" + if path not in self._size_cache: + self._size_cache[path] = intrinsic_svg_size(path) + return self._size_cache[path] # ============================================================================ @@ -1110,6 +1160,74 @@ def media_bytes_for_package(path): return svg_with_white_background(text).encode("utf-8") +def scale_for_size(width, height): + """Return the display scale for a figure of native `width`×`height`. + + `MEDIA_SCALE`× for legibility, but never so much that the enlarged + figure would grow past the `MEDIA_MAX_WIDTH` / `MEDIA_MAX_HEIGHT` + ceiling, and never below 1.0 (we only ever enlarge, never shrink — + shrinking is the CSS `max-width`'s job). A figure already at/above + either cap yields exactly 1.0, i.e. "render at native size" — so a + native figure larger than the ceiling is left as-is, not shrunk. + """ + if width <= 0 or height <= 0: + return 1.0 + scale = min( + MEDIA_SCALE, + MEDIA_MAX_WIDTH / width, + MEDIA_MAX_HEIGHT / height, + ) + return max(scale, 1.0) + + +def _svg_dimension(svg_tag, name): + """Parse a `width`/`height` length (px or unitless) off an `` tag. + + The numeric pattern is a well-formed decimal (`12`, `12.7`, `.5`) so + malformed values like `1.2.3` or a lone `.` simply don't match and + yield `None` rather than reaching `float()`. We still guard the + conversion with `try/except` so no parse can ever abort the build. + """ + attr = re.search( + rf'\b{name}\s*=\s*"(\d+(?:\.\d+)?|\.\d+)\s*(?:px)?"', + svg_tag, + flags=re.IGNORECASE, + ) + if not attr: + return None + try: + return float(attr.group(1)) + except ValueError: + return None + + +def intrinsic_svg_size(path): + """Return an SVG's intrinsic `(width, height)` in CSS px, or `None`. + + Read from the `width`/`height` attributes on the root `` tag + (the catalog's SVGs use unitless user units, which map 1:1 to px). + Only `.svg` files are handled — we deliberately don't size raster + figures, because the catalog's PNGs are already large and only the + SVGs need enlarging. Anything we can't parse confidently — a non-SVG + file, a percentage dimension, a missing attribute — returns `None` + so the caller falls back to the image's native size. + """ + if path.suffix.lower() != ".svg": + return None + try: + text = path.read_bytes().decode("utf-8-sig", errors="replace") + except OSError: + return None + match = re.search(r"]*>", text, flags=re.IGNORECASE) + if not match: + return None + width = _svg_dimension(match.group(0), "width") + height = _svg_dimension(match.group(0), "height") + if width is None or height is None: + return None + return (width, height) + + def svg_with_white_background(svg_text): """Inject a white background `` after the opening ``. diff --git a/test_amateurfunk_anki.py b/test_amateurfunk_anki.py index 5f7ccbd..8b8cc36 100644 --- a/test_amateurfunk_anki.py +++ b/test_amateurfunk_anki.py @@ -663,6 +663,162 @@ class TestAnkiBuild(unittest.TestCase): self.assertFalse(all(byte_equal)) +class TestIntrinsicSvgSize(unittest.TestCase): + """Parsing of native figure dimensions — SVG only.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + + def _write(self, name, body): + path = self.root / name + path.write_text(body, encoding="utf-8") + return path + + def test_svg_unitless_width_and_height(self): + p = self._write("a.svg", '') + self.assertEqual(aa.intrinsic_svg_size(p), (115.5, 120.0)) + + def test_svg_px_suffix_is_tolerated(self): + p = self._write("a.svg", '') + self.assertEqual(aa.intrinsic_svg_size(p), (200.0, 80.0)) + + def test_svg_percentage_dimension_is_rejected(self): + p = self._write("a.svg", '') + self.assertIsNone(aa.intrinsic_svg_size(p)) + + def test_svg_missing_dimension_returns_none(self): + p = self._write("a.svg", '') + self.assertIsNone(aa.intrinsic_svg_size(p)) + + def test_svg_without_root_tag_returns_none(self): + p = self._write("a.svg", "not an svg at all") + self.assertIsNone(aa.intrinsic_svg_size(p)) + + def test_svg_leading_decimal_is_accepted(self): + p = self._write("a.svg", '') + self.assertEqual(aa.intrinsic_svg_size(p), (0.5, 10.0)) + + def test_svg_malformed_number_returns_none_not_raises(self): + # Permissive matching used to let "1.2.3" reach float() and abort + # the build; now it falls back to None like any other bad value. + for bad in ('1.2.3', '.', '1.', '1..2'): + p = self._write("a.svg", f'') + self.assertIsNone(aa.intrinsic_svg_size(p), bad) + + def test_non_svg_is_not_sized(self): + # A PNG with a perfectly valid-looking name is still not sized: + # we deliberately only enlarge SVGs. + p = self.root / "a.png" + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64) + self.assertIsNone(aa.intrinsic_svg_size(p)) + + +class TestScaleForSize(unittest.TestCase): + """The clamped enlargement factor.""" + + def test_small_figure_gets_full_multiplier(self): + self.assertEqual(aa.scale_for_size(115, 120), aa.MEDIA_SCALE) + + def test_wide_figure_is_clamped_by_max_width(self): + self.assertEqual(aa.scale_for_size(aa.MEDIA_MAX_WIDTH, 50), 1.0) + self.assertAlmostEqual( + aa.scale_for_size(aa.MEDIA_MAX_WIDTH / 2, 50), 2.0 + ) + # Between half-cap and cap: enlarged only until width hits the cap. + wide = aa.MEDIA_MAX_WIDTH * 0.75 + self.assertAlmostEqual( + aa.scale_for_size(wide, 50), aa.MEDIA_MAX_WIDTH / wide + ) + + def test_tall_figure_is_clamped_by_max_height(self): + self.assertEqual(aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT), 1.0) + self.assertAlmostEqual( + aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT / 2), 2.0 + ) + tall = aa.MEDIA_MAX_HEIGHT * 0.75 + self.assertAlmostEqual( + aa.scale_for_size(50, tall), aa.MEDIA_MAX_HEIGHT / tall + ) + + def test_large_catalog_schematic_grows_toward_cap(self): + # NG205-like (635x460): with the 1024x768 cap it now enlarges, + # bounded by width — up to 1024 px wide. + self.assertAlmostEqual( + aa.scale_for_size(635, 460), aa.MEDIA_MAX_WIDTH / 635 + ) + + def test_never_shrinks_below_native(self): + self.assertEqual(aa.scale_for_size(5000, 5000), 1.0) + + def test_degenerate_size_is_native(self): + self.assertEqual(aa.scale_for_size(0, 0), 1.0) + + +class TestImageHtmlScaling(unittest.TestCase): + """End-to-end `` emission and the per-path size cache.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.media_dir = Path(self.tmp.name) / "svgs" + self.media_dir.mkdir() + + def _write(self, name, body): + (self.media_dir / name).write_text(body, encoding="utf-8") + + def test_small_figure_emits_doubled_width(self): + self._write("S_q.svg", '') + media = aa.MediaRegistry(self.media_dir) + self.assertIn('width="230"', media.image_html("S_q")) + + def test_figure_at_cap_emits_no_width(self): + # Exactly at the cap → scale 1.0 → native size, no width. + self._write( + "L_q.svg", + f'', + ) + media = aa.MediaRegistry(self.media_dir) + html = media.image_html("L_q") + self.assertIn('class="af-media"', html) + self.assertNotIn("width=", html) + + def test_png_is_not_scaled(self): + # Only a PNG for this stem (no .svg sibling): emitted at native + # size with no width, because we never size raster figures. + (self.media_dir / "P_q.png").write_bytes( + b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + ) + media = aa.MediaRegistry(self.media_dir) + html = media.image_html("P_q") + self.assertIn('src="P_q.png"', html) + self.assertNotIn("width=", html) + + def test_unreadable_size_falls_back_to_no_width(self): + self._write("U_q.svg", '') + media = aa.MediaRegistry(self.media_dir) + self.assertNotIn("width=", media.image_html("U_q")) + + def test_malformed_number_falls_back_without_raising(self): + self._write("M_q.svg", '') + media = aa.MediaRegistry(self.media_dir) + html = media.image_html("M_q") # must not raise + self.assertIn('src="M_q.svg"', html) + self.assertNotIn("width=", html) + + def test_size_is_read_once_per_path(self): + self._write("S_q.svg", '') + media = aa.MediaRegistry(self.media_dir) + with patch.object( + aa, "intrinsic_svg_size", wraps=aa.intrinsic_svg_size + ) as spy: + media.image_html("S_q") + media.image_html("S_q") + self.assertEqual(spy.call_count, 1) + + class TestRealExplanationsFile(unittest.TestCase): """Validate the repo's actual explanations.json against the live catalog.