GooDrive/backend/drive/word_preview.py
Louis Mazin a5fb4ad3fa nice
2026-10-14 18:47:43 +02:00

125 lines
6.6 KiB
Python

"""Offline, inert HTML previews for Word .docx files (no external services)."""
import base64
from html import escape
from pathlib import Path
from zipfile import ZipFile, BadZipFile
import xml.etree.ElementTree as ET
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
A = '{http://schemas.openxmlformats.org/drawingml/2006/main}'
R = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
MAX_DOCX = 35 * 1024 * 1024
MAX_XML = 16 * 1024 * 1024
MAX_IMAGE = 2 * 1024 * 1024
MAX_IMAGES = 12
IMAGE_MIME = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.gif': 'image/gif', '.webp': 'image/webp'}
def _zip_read(zf, path, limit=MAX_XML):
try:
item = zf.getinfo(path)
except KeyError:
return b''
if item.file_size > limit:
raise ValueError('Document Word trop volumineux pour un aperçu.')
with zf.open(item) as source:
result = source.read(limit + 1)
if len(result) > limit or b'<!DOCTYPE' in result.upper() or b'<!ENTITY' in result.upper():
raise ValueError('Contenu Word non autorisé.')
return result
def render_word_preview(path):
"""Extract safe paragraphs, tables and embedded raster images from a DOCX.
Never include hyperlinks, user HTML, macros, external sources or SVG.
Rendered inside a sandboxed iframe with strict CSP.
"""
path = Path(path)
if path.suffix.lower() != '.docx' or path.stat().st_size > MAX_DOCX:
raise ValueError('Aperçu disponible uniquement pour les fichiers DOCX de moins de 35 Mo.')
try:
with ZipFile(path) as zf:
raw = _zip_read(zf, 'word/document.xml')
if not raw:
raise ValueError('Document Word invalide.')
document = ET.fromstring(raw)
relationships = {}
relxml = _zip_read(zf, 'word/_rels/document.xml.rels', 2 * 1024 * 1024)
if relxml:
for node in ET.fromstring(relxml):
if node.attrib.get('TargetMode') == 'External':
continue
target = node.attrib.get('Target', '')
if target.startswith('media/') and '..' not in target.split('/'):
relationships[node.attrib.get('Id')] = 'word/' + target
images = {}
def image_tag(rid):
name = relationships.get(rid)
if not name or len(images) >= MAX_IMAGES and name not in images:
return ''
ext = Path(name).suffix.lower()
if ext not in IMAGE_MIME:
return ''
if name not in images:
raw_image = _zip_read(zf, name, MAX_IMAGE)
if not raw_image:
return ''
images[name] = 'data:' + IMAGE_MIME[ext] + ';base64,' + base64.b64encode(raw_image).decode('ascii')
return '<img src="' + images[name] + '" alt="Image du document" />'
def paragraph(p):
fragments = []
for run in p.iter(W + 'r'):
run_props = run.find(W + 'rPr')
styles = []
if run_props is not None:
if run_props.find(W + 'b') is not None: styles.append('font-weight:bold')
if run_props.find(W + 'i') is not None: styles.append('font-style:italic')
if run_props.find(W + 'u') is not None: styles.append('text-decoration:underline')
bits = []
for child in run.iter():
if child.tag == W + 't': bits.append(escape(child.text or ''))
elif child.tag == W + 'tab': bits.append('&emsp;')
elif child.tag == W + 'br': bits.append('<br>')
elif child.tag == A + 'blip': bits.append(image_tag(child.attrib.get(R + 'embed')))
if bits:
content = ''.join(bits)
if styles: content = '<span style="' + ';'.join(styles) + '">' + content + '</span>'
fragments.append(content)
content = ''.join(fragments) or '&nbsp;'
props = p.find(W + 'pPr')
style = props.find(W + 'pStyle') if props is not None else None
name = style.attrib.get(W + 'val', '').lower() if style is not None else ''
if 'title' in name or 'titre' in name: tag = 'h1'
elif 'heading1' in name or 'titre1' in name: tag = 'h2'
elif 'heading2' in name or 'titre2' in name: tag = 'h3'
else: tag = 'p'
return '<' + tag + '>' + content + '</' + tag + '>'
def table(tbl):
rows = []
for row in tbl.findall(W + 'tr'):
cells = []
for cell in row.findall(W + 'tc'):
cells.append('<td>' + ''.join(render_node(el) for el in cell if el.tag in (W + 'p', W + 'tbl')) + '</td>')
rows.append('<tr>' + ''.join(cells) + '</tr>')
return '<table>' + ''.join(rows) + '</table>'
def render_node(el):
if el.tag == W + 'p': return paragraph(el)
if el.tag == W + 'tbl': return table(el)
return ''
body = document.find(W + 'body')
if body is None:
raise ValueError('Document Word invalide.')
content = ''.join(render_node(element) for element in body)
except (BadZipFile, ET.ParseError, RuntimeError, EOFError, KeyError, OSError) as exc:
raise ValueError('Ce fichier DOCX ne peut pas être affiché.') from exc
title = escape(path.name)
return ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>''' + title + '''</title><style>
:root{color-scheme:light}body{margin:0;background:#e9ebef;color:#20232a;font:15px/1.6 Arial,Helvetica,sans-serif}
article{box-sizing:border-box;max-width:850px;min-height:95vh;margin:24px auto;padding:58px 62px;background:#fff;box-shadow:0 2px 20px #0001;border-radius:3px;overflow-wrap:anywhere}
h1{font-size:26px}h2{font-size:22px}h3{font-size:18px}p{margin:0 0 12px;white-space:pre-wrap}
table{border-collapse:collapse;max-width:100%;margin:14px 0}td{border:1px solid #cad0d8;padding:7px 11px;vertical-align:top}td p{margin:0 0 5px}
img{max-width:100%;height:auto;display:block;margin:12px 0}@media(max-width:650px){article{padding:26px 18px;margin:0;border-radius:0}}
</style></head><body><article>''' + content + '</article></body></html>')