-
Notifications
You must be signed in to change notification settings - Fork 0
Treat h1 as Title, restyle the default reference, add span ref targets #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,26 +1,29 @@ | ||
| #!/usr/bin/env python | ||
| """Generate the committed template `mdhtml2docx/templates/reference.docx` from a seed archive. | ||
|
|
||
| The seed (`_data/empty.docx`, a fresh empty document saved by Word 16.111) supplies theme, fonts, | ||
| settings, and Word's own modern definitions for the styles we keep; this script strips styles.xml | ||
| The seed (`_data/empty.docx`, an empty document saved by Word for the web, with header/footer | ||
| inserted and each kept style applied once so Word writes out its definitions) supplies theme, | ||
| fonts, settings, and Word's own definitions for the styles we keep; this script strips styles.xml | ||
| to exactly what STYLE_MAP needs, patches Quote for blockquote semantics (left indent, not Word's | ||
| centering), authors the definitions Word leaves latent, scrubs personal metadata, and self-verifies: | ||
| fast_checks == 'valid' and every STYLE_MAP name defined. See meta/STATUS.md, template section.""" | ||
| centering), applies the house look, authors the definitions Word leaves latent, scrubs personal | ||
| metadata, and self-verifies: fast_checks == 'valid' and every STYLE_MAP name defined.""" | ||
| import zipfile | ||
| from lxml import etree | ||
| from mdhtml2docx.styles import STYLE_MAP, style_id | ||
| from mdhtml2docx.validate import fast_checks | ||
| from mdhtml2docx.wml import E | ||
|
|
||
| W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' | ||
|
|
||
| def w(tag): return f'{{{W}}}{tag}' | ||
|
|
||
| # Styles the seed defines that we keep as Word authored them (plus linked Char twins, which | ||
| # w:link references require). Everything else the seed defines is dropped. | ||
| KEEP = {'Normal', 'DefaultParagraphFont', 'TableNormal', 'NoList', 'Quote', 'QuoteChar', 'ListParagraph', | ||
| *[f'Heading{n}' for n in range(1, 7)], *[f'Heading{n}Char' for n in range(1, 7)]} | ||
| # Styles the seed defines that we keep (restyled below where the house look differs from Word's). | ||
| KEEP = {'Normal', 'DefaultParagraphFont', 'TableNormal', 'NoList', 'Quote', 'ListParagraph', 'Title', | ||
| 'Header', 'Footer', *[f'Heading{n}' for n in range(1, 7)]} | ||
|
|
||
| # Styles Word keeps latent (definitions live inside Word, absent from the file), authored here. | ||
| font, size, line, space_after = 'Times New Roman', 11, 1.5, 11 | ||
|
|
||
| # Styles the seed cannot supply: our custom styles, plus built-ins the web UI cannot materialize. | ||
| # Built-in names are canonical (lowercase for heading/caption/footnote families); custom ones marked so. | ||
| NEW_STYLES = r'''<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> | ||
| <w:style w:type="paragraph" w:styleId="BodyText"> | ||
|
|
@@ -29,6 +32,10 @@ def w(tag): return f'{{{W}}}{tag}' | |
| <w:style w:type="paragraph" w:customStyle="1" w:styleId="FirstParagraph"> | ||
| <w:name w:val="First Paragraph"/><w:basedOn w:val="BodyText"/><w:next w:val="BodyText"/><w:uiPriority w:val="1"/><w:qFormat/> | ||
| </w:style> | ||
| <w:style w:type="paragraph" w:customStyle="1" w:styleId="Centered"> | ||
| <w:name w:val="Centered"/><w:basedOn w:val="BodyText"/><w:next w:val="BodyText"/><w:qFormat/> | ||
| <w:pPr><w:ind w:firstLine="0"/><w:jc w:val="center"/></w:pPr> | ||
| </w:style> | ||
| <w:style w:type="paragraph" w:customStyle="1" w:styleId="Compact"> | ||
| <w:name w:val="Compact"/><w:basedOn w:val="BodyText"/><w:uiPriority w:val="1"/><w:qFormat/> | ||
| <w:pPr><w:spacing w:before="0" w:after="0"/></w:pPr> | ||
|
|
@@ -88,8 +95,21 @@ def w(tag): return f'{{{W}}}{tag}' | |
| </w:style> | ||
| </w:styles>''' | ||
|
|
||
| def _hp(points): return int(round(points * 2)) | ||
| def _fonts(): return E('w:rFonts', {'w:ascii': font, 'w:hAnsi': font, 'w:eastAsia': font, 'w:cs': font}) | ||
| def _spacing(): return E('w:spacing', {'w:line': int(240 * line), 'w:lineRule': 'auto', 'w:after': space_after * 20}) | ||
| def _jc(val): return E('w:jc', {'w:val': val}) | ||
|
|
||
| def _restyle(root, sid, ppr=None, rpr=None): | ||
| "Replace style `sid`'s paragraph and run properties, dropping whatever it had" | ||
| s = root.find(f'{w("style")}[@{w("styleId")}="{sid}"]') | ||
| for t in ('pPr', 'rPr'): | ||
| if (e := s.find(w(t))) is not None: s.remove(e) | ||
| for e in (ppr, rpr): | ||
| if e is not None: s.append(e) | ||
|
|
||
| def build_styles(xml): | ||
| "Strip the seed's styles.xml to KEEP, fix Quote, append the authored definitions" | ||
| "Strip the seed's styles.xml to KEEP, patch Quote, apply the house look, append the authored definitions" | ||
| root = etree.fromstring(xml) | ||
| for s in list(root.iter(w('style'))): | ||
| if s.get(w('styleId')) not in KEEP: root.remove(s) | ||
|
|
@@ -98,11 +118,37 @@ def build_styles(xml): | |
| qp.remove(qp.find(w('jc'))) | ||
| etree.SubElement(qp, w('ind')).set(w('left'), '720') | ||
| for s in root.iter(w('style')): | ||
| if s.get(w('styleId')) in ('Quote', *[f'Heading{n}' for n in range(1, 7)]): | ||
| if s.get(w('styleId')) in ('Quote', 'Title', *[f'Heading{n}' for n in range(1, 7)]): | ||
| s.find(w('next')).set(w('val'), 'FirstParagraph') # typing after these continues our prose chain | ||
| rpd = root.find(f'{w("docDefaults")}/{w("rPrDefault")}') | ||
| rpd.replace(rpd.find(w('rPr')), E('w:rPr', _fonts(), E('w:sz', {'w:val': _hp(size)}), | ||
| E('w:szCs', {'w:val': _hp(size)}), E('w:lang', {'w:val': 'en-US'}))) | ||
| _restyle(root, 'Normal', E('w:pPr', _spacing(), E('w:ind', {'w:firstLine': 0}), _jc('both'))) | ||
| for i in range(6): | ||
| rpr = E('w:rPr', _fonts(), E('w:b'), E('w:color', {'w:val': 'auto'}), E('w:sz', {'w:val': _hp(size)}), E('w:szCs', {'w:val': _hp(size)})) | ||
| _restyle(root, f'Heading{i + 1}', E('w:pPr', _spacing(), _jc('both'), E('w:outlineLvl', {'w:val': i})), rpr) | ||
| _restyle(root, 'Title', E('w:pPr', _jc('center')), E('w:rPr', E('w:sz', {'w:val': _hp(14)}), E('w:szCs', {'w:val': _hp(14)}))) | ||
| for s in etree.fromstring(NEW_STYLES.encode()): root.append(s) | ||
| return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) | ||
|
|
||
| def footer_content(xml): | ||
| "word/footer.xml: replace the seed's empty scaffold with a centered page-number field" | ||
| root = etree.fromstring(xml) | ||
| for e in list(root): root.remove(e) | ||
| root.append(E('w:p', E('w:pPr', _jc('center')), E('w:fldSimple', {'w:instr': ' PAGE '}, E('w:r', E('w:t', '1'))))) | ||
| return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) | ||
|
|
||
|
|
||
| def doc_content(xml): | ||
| "word/document.xml: move the sectPr's header/footerReference first, as the schema requires (web Word appends them last)" | ||
| root = etree.fromstring(xml) | ||
| sect = root.find(f"{w('body')}/{w('sectPr')}") | ||
| refs = [e for e in sect if etree.QName(e).localname in ('headerReference', 'footerReference')] | ||
| for i, e in enumerate(refs): | ||
| sect.remove(e) | ||
| sect.insert(i, e) | ||
| return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| def scrub_props(xml): | ||
| "Replace personal creator/lastModifiedBy in docProps/core.xml" | ||
| root = etree.fromstring(xml) | ||
|
|
@@ -118,21 +164,26 @@ def build(seed='_data/empty.docx', out='mdhtml2docx/templates/reference.docx'): | |
| for i in z.infolist(): | ||
| data = z.read(i.filename) | ||
| if i.filename == 'word/styles.xml': data = build_styles(data) | ||
| elif i.filename == 'word/footer.xml': data = footer_content(data) | ||
| elif i.filename == 'word/document.xml': data = doc_content(data) | ||
| elif i.filename == 'docProps/core.xml': data = scrub_props(data) | ||
| zo.writestr(i.filename, data) | ||
| verify(out) | ||
| print(f'{out}: ok') | ||
|
|
||
| def verify(path): | ||
| "The template must pass fast_checks and define (not leave latent) every STYLE_MAP style" | ||
| "The template must pass fast_checks, define (not leave latent) every STYLE_MAP style, and carry the page-number footer" | ||
| r = fast_checks(path) | ||
| assert r == 'valid', r | ||
| root = etree.fromstring(zipfile.ZipFile(path).read('word/styles.xml')) | ||
| z = zipfile.ZipFile(path) | ||
| root = etree.fromstring(z.read('word/styles.xml')) | ||
| names = {s.find(w('name')).get(w('val')) for s in root.iter(w('style'))} | ||
| missing = set(STYLE_MAP.values()) - names | ||
| assert not missing, f'STYLE_MAP styles not defined: {missing}' | ||
| ids = {s.get(w('styleId')) for s in root.iter(w('style'))} | ||
| badid = {n for n in STYLE_MAP.values() if style_id(n) not in ids} | ||
| assert not badid, f'style_id mismatch for: {badid}' | ||
| assert b'PAGE' in z.read('word/footer.xml'), 'footer lacks its page-number field' | ||
| assert z.read('word/document.xml').decode().count('footerReference') == 1, 'expected exactly one footerReference' | ||
|
|
||
| if __name__ == '__main__': build() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This applies indention only to header text instead of their numbers. It looks better to @PiotrCzapla and I than indenting both, but happy to change it to the more standard indention of both the text and number