"""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 '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('
') 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 + '' def table(tbl): rows = [] for row in tbl.findall(W + 'tr'): cells = [] for cell in row.findall(W + 'tc'): cells.append('' + ''.join(render_node(el) for el in cell if el.tag in (W + 'p', W + 'tbl')) + '') rows.append('' + ''.join(cells) + '') return '' + ''.join(rows) + '
' 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 ('''''' + title + '''
''' + content + '
')