111 lines
4.9 KiB
Python
111 lines
4.9 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)
|
|
# 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()
|