125 lines
6.6 KiB
Python
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(' ')
|
|
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 ' '
|
|
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>')
|