|
| 1 | +""" |
| 2 | +One-shot, idempotent normalizer that strips inline image dimensions from News |
| 3 | +content (issue #1269). |
| 4 | +
|
| 5 | +Under CKEditor 4, inserted images carried inline ``style="width:...;height:..."`` |
| 6 | +(and sometimes ``width``/``height`` attributes). Those inline dimensions |
| 7 | +*override* the site's responsive rule (``.news-item-content img { max-width: |
| 8 | +100%; height: auto }`` in news-item.css), which is why each image historically |
| 9 | +had to be hand-edited in "source" to ``width:100%`` with its height erased. |
| 10 | +
|
| 11 | +django-prose-editor drops ``style`` on ``<img>`` when it sanitizes on save, so |
| 12 | +*new* and *re-saved* posts are already clean. This command applies the same |
| 13 | +cleanup to *legacy* rows that haven't been re-saved, so every news image becomes |
| 14 | +responsive immediately rather than lazily. It only removes width/height (from |
| 15 | +both the ``style`` attribute and the ``width``/``height`` attributes) and leaves |
| 16 | +all other markup untouched. |
| 17 | +
|
| 18 | +It is idempotent (a second run changes nothing) and writes via ``queryset |
| 19 | +.update()`` to avoid re-running model save/validation. It is wired into |
| 20 | +docker-entrypoint.sh alongside the other idempotent backfills, which is the only |
| 21 | +way to touch prod data (no shell/manage.py access on the servers). |
| 22 | +""" |
| 23 | + |
| 24 | +import re |
| 25 | + |
| 26 | +from django.core.management.base import BaseCommand |
| 27 | + |
| 28 | +from website.models import News |
| 29 | + |
| 30 | +# A whole <img ...> tag. |
| 31 | +_IMG_TAG_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE) |
| 32 | +# A width/height *attribute* on the tag: width="300", height='2', width=300. |
| 33 | +_DIM_ATTR_RE = re.compile( |
| 34 | + r'\s+(?:width|height)\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)', re.IGNORECASE |
| 35 | +) |
| 36 | +# A width/height *declaration* inside a style value: "width: 100%;". |
| 37 | +_DIM_DECL_RE = re.compile(r"\s*(?:width|height)\s*:\s*[^;]*;?", re.IGNORECASE) |
| 38 | +# The style attribute and its quoted value. |
| 39 | +_STYLE_ATTR_RE = re.compile( |
| 40 | + r'(\s+style\s*=\s*)(?:"([^"]*)"|\'([^\']*)\')', re.IGNORECASE |
| 41 | +) |
| 42 | + |
| 43 | + |
| 44 | +def _clean_style(match): |
| 45 | + """Drop width/height declarations from one style="..." attribute.""" |
| 46 | + prefix = match.group(1) |
| 47 | + double = match.group(2) |
| 48 | + quote = '"' if double is not None else "'" |
| 49 | + value = double if double is not None else match.group(3) |
| 50 | + new_value = _DIM_DECL_RE.sub("", value).strip().strip(";").strip() |
| 51 | + if not new_value: |
| 52 | + return "" # nothing left -> drop the style attribute entirely |
| 53 | + return f"{prefix}{quote}{new_value}{quote}" |
| 54 | + |
| 55 | + |
| 56 | +def _normalize_img_tag(tag): |
| 57 | + tag = _DIM_ATTR_RE.sub("", tag) |
| 58 | + tag = _STYLE_ATTR_RE.sub(_clean_style, tag) |
| 59 | + return tag |
| 60 | + |
| 61 | + |
| 62 | +def normalize_image_dimensions(html): |
| 63 | + """ |
| 64 | + Strip inline width/height (style declarations + width/height attributes) |
| 65 | + from every ``<img>`` tag in ``html``. All other markup is preserved. |
| 66 | +
|
| 67 | + >>> normalize_image_dimensions('<img src="a.jpg" style="width:100%">') |
| 68 | + '<img src="a.jpg">' |
| 69 | + """ |
| 70 | + if not html or "<img" not in html.lower(): |
| 71 | + return html |
| 72 | + return _IMG_TAG_RE.sub(lambda m: _normalize_img_tag(m.group(0)), html) |
| 73 | + |
| 74 | + |
| 75 | +class Command(BaseCommand): |
| 76 | + help = ( |
| 77 | + "Strip inline width/height from <img> tags in News.content so images " |
| 78 | + "rely on the responsive .news-item-content img CSS. Idempotent; safe to " |
| 79 | + "run repeatedly (issue #1269)." |
| 80 | + ) |
| 81 | + |
| 82 | + def handle(self, *args, **options): |
| 83 | + changed = 0 |
| 84 | + for news in News.objects.all(): |
| 85 | + original = news.content or "" |
| 86 | + cleaned = normalize_image_dimensions(original) |
| 87 | + if cleaned != original: |
| 88 | + News.objects.filter(pk=news.pk).update(content=cleaned) |
| 89 | + changed += 1 |
| 90 | + self.stdout.write(f"News id {news.pk}: normalized image dimensions") |
| 91 | + self.stdout.write( |
| 92 | + self.style.SUCCESS( |
| 93 | + f"normalize_news_image_styles: updated {changed} news item(s)." |
| 94 | + ) |
| 95 | + ) |
0 commit comments