Files
crochet/crochet/pattern_render.py
T
pablo f722f0765e
CI / test (push) Successful in 45s
CI / build (push) Successful in 27s
feat: added cover image to pattern
2026-07-17 13:48:34 +02:00

164 lines
6.9 KiB
Python

"""Renderizado del patrón a HTML plano para la vista de solo lectura.
A diferencia del editor (que construye el resultado con JS a partir del DOM
editable, ver static/js/section-types.js), esto genera HTML estático en el
servidor, sin ningún <input>/<button> ni JS de edición: así no hay nada que
"revelar" quitando una clase `hidden` desde las herramientas de desarrollo
del navegador, porque el marcado editable simplemente no existe en esta
página. Reproduce las mismas clases de Tailwind que usa cada `render` de
SECTION_TYPES para que el resultado sea visualmente idéntico.
"""
import re
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
# Mismas opciones que ofrecen los <select> de "Personalización de página"
# (ver pattern.html): los valores guardados llegan tal cual desde el POST a
# pattern_save, sin validar allí, así que se comprueban aquí antes de
# volcarlos en un atributo style/@page (ver sanitize_page_settings).
ALLOWED_FONTS = {
'sans-serif',
'serif',
'Georgia, serif',
"'Trebuchet MS', sans-serif",
"'Courier New', monospace",
}
ALLOWED_ALIGN = {'left', 'center', 'justify'}
ALLOWED_FONT_SIZES = {'0.875rem', '1rem', '1.25rem'}
ALLOWED_PAGE_SIZES = {'A4', 'A3', 'A5', 'letter', 'legal'}
ALLOWED_ORIENTATIONS = {'portrait', 'landscape'}
def _clean_color(value, default):
return value if isinstance(value, str) and HEX_COLOR_RE.match(value) else default
def sanitize_page_settings(page_settings):
"""Reduce cualquier page_settings guardado a valores conocidos y
seguros de interpolar en un atributo style/@page, ignorando (en vez de
escapar) lo que no encaje: pattern_save guarda el JSON tal cual llega en
el POST, sin validar su contenido."""
page_settings = page_settings or {}
return {
'title': str(page_settings.get('title') or '').strip(),
'author': str(page_settings.get('author') or '').strip(),
'textColor': _clean_color(page_settings.get('textColor'), '#000000'),
'accentColor': _clean_color(page_settings.get('accentColor'), '#1d4ed8'),
'bgColor': _clean_color(page_settings.get('bgColor'), '#ffffff'),
'font': page_settings.get('font') if page_settings.get('font') in ALLOWED_FONTS else 'sans-serif',
'fontSize': page_settings.get('fontSize') if page_settings.get('fontSize') in ALLOWED_FONT_SIZES else '1rem',
'align': page_settings.get('align') if page_settings.get('align') in ALLOWED_ALIGN else 'left',
'pageSize': page_settings.get('pageSize') if page_settings.get('pageSize') in ALLOWED_PAGE_SIZES else 'A4',
'orientation': (
page_settings.get('orientation') if page_settings.get('orientation') in ALLOWED_ORIENTATIONS
else 'portrait'
),
}
def _line(tag, text, css_class):
text = (text or '').strip()
if not text:
return ''
return format_html('<{0} class="{1}">{2}</{0}>', tag, css_class, text)
def _render_group(section, lang, stitch_types_by_id):
inner = mark_safe(''.join(_render_section(child, lang, stitch_types_by_id) for child in section.get('children', [])))
if not inner:
return ''
return format_html('<div class="my-2 flex flex-col gap-1">{0}</div>', inner)
def _render_note(section, lang):
text = (section.get('text') or {}).get(lang, '').strip()
if not text:
return ''
style = ''
text_color = _clean_color(section.get('textColor'), None)
bg_color = _clean_color(section.get('bgColor'), None)
if text_color:
style += f'color: {text_color};'
if bg_color:
style += f'background-color: {bg_color};'
return format_html('<div class="note-box text-[0.875em] my-2" style="{0}">{1}</div>', style, text)
def _render_materials(section, lang):
items = [(material.get(lang) or '').strip() for material in section.get('materials', [])]
items = [item for item in items if item]
if not items:
return ''
lis = format_html_join('', '<li>{0}</li>', ((item,) for item in items))
return format_html('<ul class="list-disc list-inside my-1">{0}</ul>', lis)
def _render_dnd(section, lang, stitch_types_by_id):
parts = []
for item in section.get('items', []):
stitch_type = stitch_types_by_id.get(item.get('stitchTypeId'))
if not stitch_type:
continue
label = stitch_type.get('translations', {}).get(lang, '')
parts.append(f"{item.get('count')} {label}".strip())
return _line('p', ', '.join(parts), '')
def _render_image(section):
url = section.get('url')
if not url:
return ''
return format_html('<img src="{0}" class="max-w-full max-h-96 object-contain rounded-2xl my-2">', url)
def _render_section(section, lang, stitch_types_by_id):
section_type = section.get('type')
text = (section.get('text') or {}).get(lang, '') if section_type in ('title', 'subtitle', 'text') else ''
if section_type == 'title':
return _line('h3', text, 'text-[1.125em] font-bold mt-2 mb-1 text-[var(--accent-color)]')
if section_type == 'subtitle':
return _line('h4', text, 'text-[1em] font-semibold mt-1 mb-1 text-[var(--accent-color)]')
if section_type == 'text':
return _line('p', text, '')
if section_type == 'note':
return _render_note(section, lang)
if section_type == 'materials':
return _render_materials(section, lang)
if section_type == 'dnd':
return _render_dnd(section, lang, stitch_types_by_id)
if section_type == 'image':
return _render_image(section)
if section_type == 'group':
return _render_group(section, lang, stitch_types_by_id)
return ''
def render_pattern_html(sections, page_settings, lang, stitch_types, empty_message, cover_image_url=None):
"""HTML (ya escapado y marcado como seguro) del contenido de un patrón
para `lang`, en el mismo formato que genera renderOutput() en el
editor: título, portada y autor del documento primero, y luego cada
sección de nivel superior en orden. `page_settings` debe venir ya
validado por sanitize_page_settings()."""
stitch_types_by_id = {st['id']: st for st in stitch_types}
parts = [_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1')]
# Mismas clases que _render_image() (misma pinta que una imagen de
# sección) y que la portada que pinta render.js en el editor.
if cover_image_url:
parts.append(format_html(
'<img src="{0}" class="max-w-full max-h-96 object-contain rounded-2xl my-2" alt="">', cover_image_url,
))
parts.append(_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'))
if not sections:
parts.append(format_html('<p class="opacity-50 italic">{0}</p>', empty_message))
else:
parts.extend(_render_section(section, lang, stitch_types_by_id) for section in sections)
return mark_safe(''.join(parts))