Files
intranet/worker/contentpanel_aushang/docx_template.py
T

141 lines
6.2 KiB
Python

"""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)
if '\n' in text:
paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
def _items(text):
lines = [line.strip() for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n') if line.strip()]
bullets = [line[1:].lstrip() if line.startswith('•') and line[1:].strip() else None for line in lines]
if not any(bullets):
return [(text, None)]
return [(bullet, i + 1 < len(lines) and bullets[i + 1] is not None) if bullet else (line, None)
for i, (line, bullet) in enumerate(zip(lines, bullets))]
def _format_bullet(paragraph, compact):
properties = paragraph.getElementsByTagNameNS(W, 'pPr')[0]
anchor = properties.getElementsByTagNameNS(W, 'jc')[0]
document = paragraph.ownerDocument
tabs = document.createElementNS(W, 'w:tabs')
tab = document.createElementNS(W, 'w:tab')
tab.setAttributeNS(W, 'w:val', 'left')
tab.setAttributeNS(W, 'w:pos', '480')
tabs.appendChild(tab)
properties.insertBefore(tabs, anchor)
if compact:
spacing = document.createElementNS(W, 'w:spacing')
spacing.setAttributeNS(W, 'w:after', '0')
properties.insertBefore(spacing, anchor)
indent = document.createElementNS(W, 'w:ind')
indent.setAttributeNS(W, 'w:left', '480')
indent.setAttributeNS(W, 'w:hanging', '360')
properties.insertBefore(indent, anchor)
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:
for value, compact in _items(text):
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, f'•\t{value}' if compact is not None else value)
if compact is not None:
_format_bullet(paragraph, compact)
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()