102 lines
6.2 KiB
Python
102 lines
6.2 KiB
Python
"""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', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>''')
|
|
archive.writestr('_rels/.rels', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>''')
|
|
archive.writestr('word/document.xml', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p/><w:sectPr/></w:body></w:document>''')
|
|
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'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
|
|
raise ValueError('Document non autorisé.')
|
|
root = ET.fromstring(raw)
|
|
except (OSError, KeyError, BadZipFile, ET.ParseError, RuntimeError, EOFError) as exc:
|
|
raise ValueError('Document LibreOffice illisible.') from exc
|
|
# Cap rendered elements and text to keep the preview responsive and inert.
|
|
count = 0
|
|
def t(node):
|
|
return escape(' '.join(''.join(node.itertext()).split())[:4000])
|
|
sections = []
|
|
if ext == 'odt':
|
|
for el in root.iter():
|
|
if el.tag in (TEXT + 'p', TEXT + 'h'):
|
|
count += 1
|
|
if count > 2500: break
|
|
tag = 'h2' if el.tag == TEXT + 'h' else 'p'
|
|
sections.append(f'<{tag}>' + (t(el) or ' ') + f'</{tag}>')
|
|
elif ext == 'ods':
|
|
for table in root.iter(TABLE + 'table'):
|
|
if len(sections) >= 12: break
|
|
sections.append('<h2>' + escape(table.attrib.get(TABLE + 'name', 'Feuille')) + '</h2><table>')
|
|
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(['<td>' + cell_text + '</td>'] * min(repeat, max(0, 60 - len(cells))))
|
|
if len(cells) >= 60: break
|
|
if cells: sections.append('<tr>' + ''.join(cells) + '</tr>')
|
|
sections.append('</table>')
|
|
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('<section><h2>' + name + '</h2>' + ''.join('<p>' + x + '</p>' for x in paras) + '</section>')
|
|
content = ''.join(sections) or '<p>Document vide</p>'
|
|
return ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
|
body{margin:0;background:#e9ecf1;color:#20242b;font:15px/1.6 Arial,sans-serif}main{box-sizing:border-box;max-width:1080px;margin:24px auto;padding:40px 48px;min-height:80vh;background:#fff;box-shadow:0 2px 14px #0001}
|
|
p{white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0}h2{font-size:19px}table{border-collapse:collapse;width:max-content;max-width:100%;margin:14px 0}td{min-width:80px;border:1px solid #c9ced8;padding:6px 9px}section{margin:25px auto;padding:20px;border:1px solid #ddd;min-height:220px} @media(max-width:650px){main{margin:0;padding:18px}}</style></head><body><main>'''
|
|
+ content + '</main></body></html>')
|