diff --git a/dandiapi/api/slides/__init__.py b/dandiapi/api/slides/__init__.py new file mode 100644 index 000000000..ff1a8321a --- /dev/null +++ b/dandiapi/api/slides/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from dandiapi.api.slides.render import render_shareable_slide_svg + +__all__ = ['render_shareable_slide_svg'] diff --git a/dandiapi/api/slides/dandi_logo.svg b/dandiapi/api/slides/dandi_logo.svg new file mode 100644 index 000000000..615edda2d --- /dev/null +++ b/dandiapi/api/slides/dandi_logo.svg @@ -0,0 +1,78 @@ + + + + + + + + + + diff --git a/dandiapi/api/slides/render.py b/dandiapi/api/slides/render.py new file mode 100644 index 000000000..c276fb963 --- /dev/null +++ b/dandiapi/api/slides/render.py @@ -0,0 +1,322 @@ +"""Render a CC-0 shareable slide for a published Dandiset Version as SVG. + +Produces a 1920x1080 SVG document summarizing a Version (title, DOI, contributors, +key stats, citation, QR code) so researchers can drop it into talks. PNG +rasterization is left to the caller (e.g. librsvg / rsvg-convert at the email +or storage layer). + +Not yet wired into the publish flow; see issue #2797. +""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +import re +from typing import TYPE_CHECKING +from xml.sax.saxutils import escape + +import qrcode +from qrcode.image.svg import SvgPathImage + +if TYPE_CHECKING: + from dandiapi.api.models import Version + +# --- Layout ------------------------------------------------------------------ +W = 1920 +H = 1080 + +# --- Brand palette (from web/src/assets/logo.svg + Vuetify theme) ----------- +NAVY = '#00436D' +CORAL = '#D3868D' +INK = '#1a1a1a' +MUTED = '#5b6b7d' +BG = '#ffffff' +PANEL = '#f4f7fa' + +# --- Logo asset (verbatim copy of web/src/assets/logo.svg) ------------------ +_LOGO_PATH = Path(__file__).parent / 'dandi_logo.svg' + +# Map the source classes to direct fill attrs so +# the logo renders correctly when embedded as an data URI (rsvg-convert +# does not apply CSS rules across image boundaries). +_LOGO_FILL_BY_CLASS = { + 'st0': '#D3868D', + 'st1': '#F0A5AC', + 'st2': '#A05A60', + 'st3': '#FFFFFF', + 'st4': '#00436D', +} + + +def _inline_logo_fills(svg_text: str) -> str: + out = svg_text + for cls, color in _LOGO_FILL_BY_CLASS.items(): + out = re.sub(rf'class="{cls}"', f'fill="{color}"', out) + return re.sub(r']*>.*?', '', out, flags=re.DOTALL) + + +_LOGO_SVG_INLINED = _inline_logo_fills(_LOGO_PATH.read_text(encoding='utf-8')) + +# Extract the inner content of the logo so it can be nested as a child +# element in the composed slide (cleaner than data-URI encoding). +_LOGO_INNER_MATCH = re.search(r']*>(.*)', _LOGO_SVG_INLINED, re.DOTALL) +_LOGO_SVG_INLINED_INNER = _LOGO_INNER_MATCH.group(1) if _LOGO_INNER_MATCH else '' + + +# --- Formatting helpers ------------------------------------------------------ +_MODALITY_LABELS = { + 'electrophysiological approach': 'Ecephys', + 'behavioral approach': 'Behavior', + 'optical physiology approach': 'Ophys', + 'imaging approach': 'Imaging', +} + + +def _fmt_size(n: int) -> str: + """SI base-10 formatting to match the Dandiset Landing Page convention.""" + value = float(n) + for unit in ('B', 'kB', 'MB', 'GB', 'TB', 'PB'): + if value < 1000: + return f'{value:.1f} {unit}' + value /= 1000 + return f'{value:.1f} EB' + + +def _fmt_count(n: int) -> str: + if n >= 1_000_000: + return f'{n / 1_000_000:.1f}M' + if n >= 1_000: + return f'{n / 1_000:.1f}k' + return str(n) + + +def _reformat_person(name: str) -> str: + """Convert DANDI's "Last, First Middle" name format to "F. Last".""" + if ',' not in name: + return name + last, first = (s.strip() for s in name.split(',', 1)) + initials = ''.join(f'{part[0]}.' for part in first.split() if part) + return f'{initials} {last}'.strip() if initials else last + + +def _strip_prefix(s: str, prefix: str) -> str: + return s.removeprefix(prefix) + + +def _truncate_at_word(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + return text[:max_chars].rsplit(' ', 1)[0] + '…' + + +def _wrap(text: str, width: int) -> list[str]: + words = text.split() + lines: list[str] = [] + cur = '' + for w in words: + trial = (cur + ' ' + w).strip() + if len(trial) > width: + lines.append(cur) + cur = w + else: + cur = trial + if cur: + lines.append(cur) + return lines + + +def _truncate_contributors(names: list[str], max_chars: int) -> str: + """Join names up to a character budget, appending "+N more" overflow.""" + shown: list[str] = [] + running = 0 + overflow_reserve = 12 # room for a trailing "+NN more" + for n in names: + add = (2 if shown else 0) + len(n) + if running + add > max_chars - overflow_reserve: + break + shown.append(n) + running += add + line = ', '.join(shown) + remaining = len(names) - len(shown) + if remaining > 0: + line += f', +{remaining} more' + return line + + +def _qr_svg(url: str, size: int) -> str: + """Render a QR code as an SVG snippet sized to fit `size` x `size`.""" + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=0, + ) + qr.add_data(url) + qr.make(fit=True) + img = qr.make_image(image_factory=SvgPathImage, fill_color=NAVY) + buf = BytesIO() + img.save(buf) + inner = buf.getvalue().decode('utf-8') + # SvgPathImage emits a full element with its own width/height. + # Inline only the path so we can position and scale it ourselves. + match = re.search(r']*viewBox="([^"]+)"[^>]*>(.*)', inner, re.DOTALL) + if not match: + return '' + view_box, body = match.group(1), match.group(2) + return ( + f'{body}' + ) + + +# --- Metadata extraction ----------------------------------------------------- +def _extract_data(version: Version) -> dict: + """Pull the fields the slide needs out of a Version's metadata payload.""" + metadata: dict = version.metadata or {} + summary: dict = metadata.get('assetsSummary') or {} + + cited = [c for c in metadata.get('contributor', []) if c.get('includeInCitation')] + orgs = [c['name'] for c in cited if c.get('schemaKey') == 'Organization'] + persons = [_reformat_person(c['name']) for c in cited if c.get('schemaKey') == 'Person'] + contributors = orgs + persons + + modalities = [ + _MODALITY_LABELS.get(a.get('name', ''), a.get('name', '')) + for a in summary.get('approach', []) + ] + standards = [ + s.get('name', '').replace('Neurodata Without Borders (NWB)', 'NWB') + for s in summary.get('dataStandard', []) + ] + species = [s.get('name', '').split(' - ')[0] for s in summary.get('species', [])] + + licenses = [_strip_prefix(lic, 'spdx:') for lic in metadata.get('license', [])] + published = (metadata.get('datePublished') or '')[:10] + + return { + 'identifier': version.dandiset.identifier, + 'version': version.version, + 'title': metadata.get('name') or version.name or '', + 'doi': metadata.get('doi') or version.doi or '', + 'description': metadata.get('description') or '', + 'contributors': contributors, + 'file_count': summary.get('numberOfFiles', 0), + 'size_bytes': summary.get('numberOfBytes', 0), + 'species': species, + 'modalities': modalities, + 'standards': standards, + 'license': ', '.join(licenses) or '—', + 'published': published, + } + + +# --- SVG composition --------------------------------------------------------- +def _build_svg(data: dict) -> str: + title = escape(data['title']) + doi = escape(data['doi']) + identifier = escape(data['identifier']) + version = escape(data['version']) + published = escape(data['published']) + + desc_text = _truncate_at_word(data['description'], 240) + desc_lines = _wrap(desc_text, 60) + desc_tspans = '\n'.join( + f'{escape(line)}' + for i, line in enumerate(desc_lines) + ) + + contributors_line = escape(_truncate_contributors(data['contributors'], max_chars=80)) + + standards = data['standards'] + standards_label = 'Standards' if len(standards) > 1 else 'Standard' + standards_value = ' · '.join(standards) if standards else '—' + modalities_value = ' · '.join(data['modalities']) or '—' + species_value = '; '.join(data['species']) or '—' + + stats: list[tuple[str, str]] = [ + ('Files', _fmt_count(data['file_count'])), + ('Size', _fmt_size(data['size_bytes'])), + (standards_label, standards_value), + ('License', escape(data['license'])), + ('Species', escape(species_value)), + ('Modalities', escape(modalities_value)), + ] + + stat_rows: list[str] = [] + y0 = 360 + for i, (label, value) in enumerate(stats): + y = y0 + i * 80 + stat_rows.append( + f'{escape(label).upper()}' + f'{escape(value)}' + ) + + landing_url = f'https://dandiarchive.org/dandiset/{data["identifier"]}/{data["version"]}' + qr_block = _qr_svg(landing_url, size=140) + + return f""" + + + + + {_LOGO_SVG_INLINED_INNER} + + + + DANDI:{identifier} + + + {title} + + {desc_tspans} + + CONTRIBUTORS + {contributors_line} + + CITE THIS DATASET + doi:{doi} + + Published {published} · v{version} + + + {qr_block} + SCAN OR VISIT + dandiarchive.org/dandiset/{identifier} + + + + DATASET + {''.join(stat_rows)} + + + +""" + + +def render_shareable_slide_svg(version: Version) -> str: + """Render the shareable slide for a published Version as an SVG document.""" + data = _extract_data(version) + return _build_svg(data) diff --git a/pyproject.toml b/pyproject.toml index 9091bb2fb..aca0bbf38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "more_itertools==10.8.0", "psycopg[binary]==3.2.10", "pyyaml==6.0.3", + "qrcode==8.2", "requests==2.32.5", "rich==14.2.0", "whitenoise[brotli]==6.11.0", diff --git a/uv.lock b/uv.lock index 1e63fbc38..b5088ad3a 100644 --- a/uv.lock +++ b/uv.lock @@ -752,6 +752,7 @@ dependencies = [ { name = "more-itertools" }, { name = "psycopg", extra = ["binary"] }, { name = "pyyaml" }, + { name = "qrcode" }, { name = "requests" }, { name = "rich" }, { name = "sentry-sdk", extra = ["celery", "django", "pure-eval"] }, @@ -837,6 +838,7 @@ requires-dist = [ { name = "more-itertools", specifier = "==10.8.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.10" }, { name = "pyyaml", specifier = "==6.0.3" }, + { name = "qrcode", specifier = "==8.2" }, { name = "requests", specifier = "==2.32.5" }, { name = "rich", specifier = "==14.2.0" }, { name = "sentry-sdk", extras = ["celery", "django", "pure-eval"], specifier = "==2.41.0" }, @@ -2883,6 +2885,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "referencing" version = "0.37.0"