GooDrive/backend/drive/document_formats.py
Louis Mazin 3fea8a85c8 perfect
2026-10-14 19:17:56 +02:00

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 '&nbsp;') + 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>')