"""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' 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 ''
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('
')
elif child.tag == A + 'blip': bits.append(image_tag(child.attrib.get(R + 'embed')))
if bits:
content = ''.join(bits)
if styles: content = '' + content + ''
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('