"""Small valid, editable blank ODF/DOCX documents and inert previews. No cloud APIs, external requests, macros or document execution. """ from html import escape from io import BytesIO from pathlib import Path from zipfile import ZIP_DEFLATED, ZipFile, BadZipFile import xml.etree.ElementTree as ET ODF_TYPES = { 'odt': ('application/vnd.oasis.opendocument.text', 'text'), 'ods': ('application/vnd.oasis.opendocument.spreadsheet', 'spreadsheet'), 'odp': ('application/vnd.oasis.opendocument.presentation', 'presentation'), } BLANK_TYPES = {'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', **{ext: mime for ext, (mime, _) in ODF_TYPES.items()}, **{ext: 'text/plain; charset=utf-8' for ext in ('txt', 'md', 'py', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'json', 'xml', 'csv')}} ODF_NS = 'urn:oasis:names:tc:opendocument:xmlns:' OFFICE = '{' + ODF_NS + 'office:1.0}' TEXT = '{' + ODF_NS + 'text:1.0}' TABLE = '{' + ODF_NS + 'table:1.0}' DRAW = '{' + ODF_NS + 'drawing:1.0}' MAX_DOC_BYTES = 35 * 1024 * 1024 MAX_CONTENT_BYTES = 12 * 1024 * 1024 def blank_file(extension): if extension not in BLANK_TYPES: raise ValueError('Format non autorisé.') if extension in ODF_TYPES: # Valid empty templates tested with LibreOffice, no server dependency. return (Path(__file__).with_name('templates') / f'blank.{extension}').read_bytes() if extension != 'docx': return b'' if extension != 'json' else b'{}\n' out = BytesIO() with ZipFile(out, 'w', ZIP_DEFLATED) as archive: archive.writestr('[Content_Types].xml', '''''') archive.writestr('_rels/.rels', '''''') archive.writestr('word/document.xml', '''''') return out.getvalue() def render_odf_preview(path): """Render plain text/cells/slides only; no untrusted HTML or external resources.""" path = Path(path) ext = path.suffix.lower().lstrip('.') if ext not in ODF_TYPES or path.stat().st_size > MAX_DOC_BYTES: raise ValueError('Format ou taille de document incompatible.') try: with ZipFile(path) as archive: info = archive.getinfo('content.xml') if info.file_size > MAX_CONTENT_BYTES: raise ValueError('Document trop volumineux.') with archive.open(info) as stream: raw = stream.read(MAX_CONTENT_BYTES + 1) if len(raw) > MAX_CONTENT_BYTES or b' 2500: break tag = 'h2' if el.tag == TEXT + 'h' else 'p' sections.append(f'<{tag}>' + (t(el) or ' ') + f'') elif ext == 'ods': for table in root.iter(TABLE + 'table'): if len(sections) >= 12: break sections.append('

' + escape(table.attrib.get(TABLE + 'name', 'Feuille')) + '

') for row in table.iter(TABLE + 'table-row'): count += 1 if count > 1500: break cells = [] for cell in row: if cell.tag in (TABLE + 'table-cell', TABLE + 'covered-table-cell'): cell_text = t(cell) repeat = min(int(cell.attrib.get(TABLE + 'number-columns-repeated', '1') or '1'), 100) cells.extend([''] * min(repeat, max(0, 60 - len(cells)))) if len(cells) >= 60: break if cells: sections.append('' + ''.join(cells) + '') sections.append('
' + cell_text + '
') else: for page in root.iter(DRAW + 'page'): count += 1 if count > 120: break name = escape(page.attrib.get(DRAW + 'name', f'Diapositive {count}')) paras = [t(p) for p in page.iter() if p.tag in (TEXT + 'p', TEXT + 'h')][:100] sections.append('

' + name + '

' + ''.join('

' + x + '

' for x in paras) + '
') content = ''.join(sections) or '

Document vide

' return ('''
''' + content + '
')