164 lines
6.9 KiB
Python
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))
|