Scale SVG images bigger
This commit is contained in:
+120
-2
@@ -165,6 +165,23 @@ TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
|
|||||||
# inside the package as a deck tree.
|
# inside the package as a deck tree.
|
||||||
TOPIC_SPLIT_CLASSES = frozenset({"E", "A"})
|
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
|
# Fallback build epoch if neither the manifest nor `--epoch` supplies
|
||||||
# one. Picked as 0 so missing-metadata builds are still deterministic
|
# one. Picked as 0 so missing-metadata builds are still deterministic
|
||||||
# and obviously wrong (timestamps would all show 1970).
|
# and obviously wrong (timestamps would all show 1970).
|
||||||
@@ -837,6 +854,8 @@ class MediaRegistry:
|
|||||||
self.used_paths = set()
|
self.used_paths = set()
|
||||||
# references that didn't resolve (recorded for diagnostics)
|
# references that didn't resolve (recorded for diagnostics)
|
||||||
self.missing = []
|
self.missing = []
|
||||||
|
# Path → intrinsic (w, h) in px (memoised; None when unreadable)
|
||||||
|
self._size_cache = {}
|
||||||
self._index_media_dir()
|
self._index_media_dir()
|
||||||
|
|
||||||
def _index_media_dir(self):
|
def _index_media_dir(self):
|
||||||
@@ -878,12 +897,43 @@ class MediaRegistry:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def image_html(self, reference):
|
def image_html(self, reference):
|
||||||
"""Return an `<img>` tag for `reference`, or `""` if unresolved."""
|
"""Return an `<img>` 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)
|
path = self.resolve(reference)
|
||||||
if path is None:
|
if path is None:
|
||||||
return ""
|
return ""
|
||||||
filename = html.escape(path.name, quote=True)
|
filename = html.escape(path.name, quote=True)
|
||||||
return f'<img src="{filename}" class="af-media">'
|
size = self._intrinsic_svg_size(path)
|
||||||
|
if size is None:
|
||||||
|
return f'<img src="{filename}" class="af-media">'
|
||||||
|
width, height = size
|
||||||
|
scale = scale_for_size(width, height)
|
||||||
|
if scale <= 1.0:
|
||||||
|
return f'<img src="{filename}" class="af-media">'
|
||||||
|
scaled = round(width * scale)
|
||||||
|
return f'<img src="{filename}" class="af-media" width="{scaled}">'
|
||||||
|
|
||||||
|
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")
|
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 `<svg>` 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 `<svg>` 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"<svg\b[^>]*>", 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):
|
def svg_with_white_background(svg_text):
|
||||||
"""Inject a white background `<rect>` after the opening `<svg>`.
|
"""Inject a white background `<rect>` after the opening `<svg>`.
|
||||||
|
|
||||||
|
|||||||
@@ -663,6 +663,162 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
self.assertFalse(all(byte_equal))
|
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", '<svg width="115.5" height="120"><g/></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", '<svg width="200px" height="80px"></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", '<svg width="100%" height="100%"></svg>')
|
||||||
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
||||||
|
|
||||||
|
def test_svg_missing_dimension_returns_none(self):
|
||||||
|
p = self._write("a.svg", '<svg width="200"></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", '<svg width=".5" height="10"></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'<svg width="{bad}" height="10"></svg>')
|
||||||
|
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 `<img>` 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", '<svg width="115" height="120"></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'<svg width="{aa.MEDIA_MAX_WIDTH}" '
|
||||||
|
f'height="{aa.MEDIA_MAX_HEIGHT}"></svg>',
|
||||||
|
)
|
||||||
|
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", '<svg width="100%" height="100%"></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", '<svg width="1.2.3" height="10"></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", '<svg width="115" height="120"></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):
|
class TestRealExplanationsFile(unittest.TestCase):
|
||||||
"""Validate the repo's actual explanations.json against the live catalog.
|
"""Validate the repo's actual explanations.json against the live catalog.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user