contentpanel - Aushangworkflow um docx Vorlage erweitern
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user