"""Fill the checked-in layout; preserve every other DOCX part byte for byte.""" import io import json import re import zipfile from pathlib import Path from xml.dom import minidom W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' W14 = 'http://schemas.microsoft.com/office/word/2010/wordml' TITLE_TOKEN, DATE_TOKEN, BODY_TOKEN = TOKENS = ( '[[AUSHANG_TITLE]]', '[[AUSHANG_DATE]]', '[[AUSHANG_BODY]]', ) FONT_FACES = json.loads((Path(__file__).with_name('renderer') / 'fonts.json').read_text()) class TemplateError(ValueError): pass def _placeholders(document): found = {} for token in TOKENS: matches = [p for p in document.getElementsByTagNameNS(W, 'p') if ''.join(t.firstChild.data if t.firstChild else '' for t in p.getElementsByTagNameNS(W, 't')) == token] if len(matches) != 1: raise TemplateError(f'Vorlage muss genau einen Absatz {token} enthalten.') found[token] = matches[0] return found def validate_template(template: Path) -> None: with zipfile.ZipFile(template) as archive: _placeholders(minidom.parseString(archive.read('word/document.xml'))) def _set_text(paragraph, text): if any(ord(char) < 32 and char not in '\t\n\r' for char in text): raise TemplateError('Text enthält ungültige Steuerzeichen.') nodes = paragraph.getElementsByTagNameNS(W, 't') for node in nodes: while node.firstChild: node.removeChild(node.firstChild) document, anchor = paragraph.ownerDocument, nodes[0] text = text.replace('\r\n', '\n').replace('\r', '\n') for part in re.split(r'(\n|\t)', text): if part in ('\n', '\t'): node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab') else: node = document.createElementNS(W, 'w:t') node.setAttribute('xml:space', 'preserve') node.appendChild(document.createTextNode(part)) anchor.parentNode.insertBefore(node, anchor) anchor.parentNode.removeChild(anchor) # Preserve verse/signature lines without stretching them to the full width. if '\n' in text: for alignment in paragraph.getElementsByTagNameNS(W, 'jc'): alignment.setAttributeNS(W, 'w:val', 'left') def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None: paragraphs = [p.strip() for p in paragraphs if p.strip()] if not title.strip() or not date_label.strip() or not paragraphs: raise TemplateError('Titel, Datum und Inhalt dürfen nicht leer sein.') with zipfile.ZipFile(template) as source: document = minidom.parseString(source.read('word/document.xml')) placeholders = _placeholders(document) _set_text(placeholders[TITLE_TOKEN], title.strip()) _set_text(placeholders[DATE_TOKEN], date_label.strip()) body = placeholders[BODY_TOKEN] used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')} index = 1 for text in paragraphs: paragraph = body.cloneNode(deep=True) while f'{index:08X}' in used: index += 1 paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}') used.add(f'{index:08X}') _set_text(paragraph, text) body.parentNode.insertBefore(paragraph, body) body.parentNode.removeChild(body) output.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(output, 'w') as target: for info in source.infolist(): data = document.toxml(encoding='utf-8') if info.filename == 'word/document.xml' else source.read(info) target.writestr(info, data) def renderer_document(document: Path) -> bytes: # BetterOffice 0.1.0 emits unquoted CSS font names: alias the apostrophe in # Suisse Int'l only in this disposable render copy, never in the Word file. aliases = {face['family']: face['alias'] for face in FONT_FACES} output = io.BytesIO() with zipfile.ZipFile(document) as source, zipfile.ZipFile(output, 'w') as target: for info in source.infolist(): data = source.read(info) if info.filename.startswith('word/') and info.filename.endswith('.xml'): xml = minidom.parseString(data) changed = False for tag in ('rFonts', 'font'): for node in xml.getElementsByTagNameNS(W, tag): for attr in node.attributes.values(): if attr.value in aliases: attr.value = aliases[attr.value] changed = True if changed: data = xml.toxml(encoding='utf-8') target.writestr(info, data) return output.getvalue()