mdhtml2docx 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/PKG-INFO +5 -5
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/README.md +2 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/__init__.py +2 -1
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/hilite.py +1 -4
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/mdhtml2docx.py +42 -105
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/styles.py +2 -4
- mdhtml2docx-0.1.5/mdhtml2docx/templates/reference.docx +0 -0
- mdhtml2docx-0.1.5/mdhtml2docx/wml.py +30 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/PKG-INFO +5 -5
- mdhtml2docx-0.1.5/mdhtml2docx.egg-info/SOURCES.txt +16 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/requires.txt +2 -5
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/pyproject.toml +3 -4
- mdhtml2docx-0.1.5/tests/test_check_docx.py +11 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/tests/test_convert.py +181 -180
- mdhtml2docx-0.1.4/mdhtml2docx/asvocab.py +0 -91
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/SOURCES.md +0 -7
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-chart.xsd +0 -1499
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-chartDrawing.xsd +0 -146
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-diagram.xsd +0 -1085
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-lockedCanvas.xsd +0 -11
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-main.xsd +0 -3081
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-picture.xsd +0 -23
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-wordprocessingDrawing.xsd +0 -287
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-commonSimpleTypes.xsd +0 -172
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-customXmlSchemaProperties.xsd +0 -18
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-math.xsd +0 -582
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-relationshipReference.xsd +0 -25
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/wml.xsd +0 -3643
- mdhtml2docx-0.1.4/mdhtml2docx/schemas/xml.xsd +0 -287
- mdhtml2docx-0.1.4/mdhtml2docx/templates/reference.docx +0 -0
- mdhtml2docx-0.1.4/mdhtml2docx/validate.py +0 -42
- mdhtml2docx-0.1.4/mdhtml2docx/wml.py +0 -100
- mdhtml2docx-0.1.4/mdhtml2docx/word.py +0 -136
- mdhtml2docx-0.1.4/mdhtml2docx/word.sdef +0 -9661
- mdhtml2docx-0.1.4/mdhtml2docx.egg-info/SOURCES.txt +0 -34
- mdhtml2docx-0.1.4/tests/test_validate.py +0 -34
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/LICENSE +0 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/dependency_links.txt +0 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/top_level.txt +0 -0
- {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdhtml2docx
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Convert MDHTML to Word docx files
|
|
5
5
|
Author-email: Jeremy Howard <j@fast.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -12,11 +12,9 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: mdhtml>=0.1.39
|
|
14
14
|
Requires-Dist: fast5ever>=0.1.5
|
|
15
|
-
Requires-Dist: oxml>=0.1.
|
|
16
|
-
Provides-Extra: validation
|
|
17
|
-
Requires-Dist: lxml; extra == "validation"
|
|
15
|
+
Requires-Dist: oxml>=0.1.4
|
|
18
16
|
Provides-Extra: dev
|
|
19
|
-
Requires-Dist:
|
|
17
|
+
Requires-Dist: lxml; extra == "dev"
|
|
20
18
|
Requires-Dist: pytest; extra == "dev"
|
|
21
19
|
Requires-Dist: fastship; extra == "dev"
|
|
22
20
|
Requires-Dist: build; extra == "dev"
|
|
@@ -175,3 +173,5 @@ Reference targets must be headings, paragraphs, figures, or tables with ids. A r
|
|
|
175
173
|
Independent schema validation is optional. Install `mdhtml2docx[validation]` to use `mdhtml2docx.validate.fast_checks(path)`; this extra requires lxml. Normal conversion does not import it.
|
|
176
174
|
|
|
177
175
|
The test suite checks docx containers, CRCs, and XML, validates against the ECMA-376 schemas with lxml, and performs semantic round trips through Pandoc's independent docx reader. Periodic acceptance runs open documents in Microsoft Word through AppleScript.
|
|
176
|
+
|
|
177
|
+
Live Word scripting is provided by [macscript](https://github.com/AnswerDotAI/macscript), not the converter. The development-only `tools/check_docx.py` combines `oxml` validation with a live Word acceptance check; see "Live Word checks" in [DEV.md](DEV.md). Normal installation and conversion do not require macscript or Office.
|
|
@@ -150,3 +150,5 @@ Reference targets must be headings, paragraphs, figures, or tables with ids. A r
|
|
|
150
150
|
Independent schema validation is optional. Install `mdhtml2docx[validation]` to use `mdhtml2docx.validate.fast_checks(path)`; this extra requires lxml. Normal conversion does not import it.
|
|
151
151
|
|
|
152
152
|
The test suite checks docx containers, CRCs, and XML, validates against the ECMA-376 schemas with lxml, and performs semantic round trips through Pandoc's independent docx reader. Periodic acceptance runs open documents in Microsoft Word through AppleScript.
|
|
153
|
+
|
|
154
|
+
Live Word scripting is provided by [macscript](https://github.com/AnswerDotAI/macscript), not the converter. The development-only `tools/check_docx.py` combines `oxml` validation with a live Word acceptance check; see "Live Word checks" in [DEV.md](DEV.md). Normal installation and conversion do not require macscript or Office.
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
"
|
|
2
|
-
When fastpylight is absent or the language unknown, callers fall back to plain runs, so the
|
|
3
|
-
package does not require fastpylight. Colors live in the reference doc's Hl* character
|
|
4
|
-
styles (see styles.theme_styles); this module only tokenizes."""
|
|
1
|
+
"Optional syntax scopes for code blocks, via fastpylight When fastpylight is absent or the language unknown, callers fall back to plain runs, so the package does not require fastpylight. Colors live in the reference doc's Hl* character styles (see styles.theme_styles); this module only tokenizes."
|
|
5
2
|
|
|
6
3
|
# theme_colors is probed too: a pre-theme_colors fastpylight is treated as absent, not half-working
|
|
7
4
|
try: from fastpylight import tokenize, theme_colors # chkstyle: ignore
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
The reference template supplies styles/theme/fonts; oxml builds the generated XML and
|
|
4
4
|
owns its package, parts, and relationships. Block and inline walkers mirror the MDHTML element
|
|
5
5
|
inventory; STYLE_MAP names every style we emit."""
|
|
6
|
-
import
|
|
6
|
+
import re
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from fast5ever import Comment, Element, Node, Text
|
|
9
9
|
from mdhtml import mdhtml2dom
|
|
10
10
|
from mdhtml.export import REFTYPES, SCHEMES, decode_raw, tmpl_node, group_plan, ref_tokens, ref_variant, target_kind, Resolver, panel_parts
|
|
11
|
-
from oxml import Document, Tree, w
|
|
11
|
+
from oxml import Document, Tree, bookmark_name, emu, field, w
|
|
12
12
|
from .styles import STYLE_MAP, style_id, theme_styles
|
|
13
13
|
from .styles import ref_path as _refpath
|
|
14
14
|
from .wml import *
|
|
@@ -22,13 +22,7 @@ _partxml = E('w', attr_ns='w', ns=NS)
|
|
|
22
22
|
|
|
23
23
|
def _sid(key): return style_id(STYLE_MAP[key])
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
source = source or package.main_part
|
|
27
|
-
rel = next((r for r in package.relationships(source) if r['type'] == f'{R}/{kind}'), None)
|
|
28
|
-
return package.relationship_part(source, rel['id']) if rel else None
|
|
29
|
-
|
|
30
|
-
WPML = 'application/vnd.openxmlformats-officedocument.wordprocessingml'
|
|
31
|
-
IMAGE_TYPES = dict(png='image/png', jpeg='image/jpeg', jpg='image/jpeg', gif='image/gif', tiff='image/tiff')
|
|
25
|
+
PARTS = dict(styles='StyleDefinitionsPart', numbering='NumberingDefinitionsPart')
|
|
32
26
|
|
|
33
27
|
BLOCK_TAGS = set(('address article aside blockquote details dialog div dl fieldset figure footer form h1 h2 h3 h4 h5 h6 '
|
|
34
28
|
'header hgroup hr main menu nav ol p pre search section table ul').split())
|
|
@@ -54,19 +48,14 @@ class Converter:
|
|
|
54
48
|
elif not isinstance(reference, (list, tuple)): reference = [reference]
|
|
55
49
|
self.doc = Document.open(reference[0] or _refpath())
|
|
56
50
|
self.package = self.doc.package
|
|
57
|
-
self.part_uris = {kind: _related_part(self.package, kind) for kind in ('styles', 'numbering', 'settings', 'footnotes')}
|
|
58
51
|
self._source_uri = self.package.main_part
|
|
59
|
-
self._part_names = {p.lower() for p in self.package.part_names()}
|
|
60
52
|
self.base = Path(base or '.')
|
|
61
53
|
self.warnings = []
|
|
62
54
|
self.bq = 0 # blockquote nesting depth
|
|
63
55
|
self._bkid = 0 # bookmark id counter
|
|
64
|
-
self._imgn = 0 # image part counter (doubles as docPr id)
|
|
65
|
-
self._urlrids = {} # (source part, hyperlink URL) -> rId
|
|
66
56
|
self.nums = [] # (numId, abstractNumId, start) per list instance
|
|
67
57
|
self.fndefs = {} # endnote li elements by id, harvested before the walk
|
|
68
|
-
self.fnids = {} # endnote id ->
|
|
69
|
-
self.fnotes = [] # (w:id, [footnote blocks])
|
|
58
|
+
self.fnids = {} # endnote id -> Footnote
|
|
70
59
|
self.stubs = {} # undefined custom-style name -> (kind, styleId)
|
|
71
60
|
self.first = True # next body paragraph is a 'First Paragraph' (doc start; reset after FIRST_AFTER blocks)
|
|
72
61
|
self._bknames = {} # element id -> Word-legal bookmark name
|
|
@@ -74,19 +63,20 @@ class Converter:
|
|
|
74
63
|
self.has_controls = False
|
|
75
64
|
self.bound = [] # distinct bound-control names, in first-appearance order
|
|
76
65
|
self.reftypes = REFTYPES | (reftypes or {})
|
|
77
|
-
self.sectpr =
|
|
78
|
-
pg, mar =
|
|
66
|
+
self.sectpr = self.doc.main.xml.root.child(w.Body).child(w.SectionProperties)
|
|
67
|
+
pg, mar = self.sectpr.child(w.PageSize), self.sectpr.child(w.PageMargin)
|
|
79
68
|
self.content_w = int(pg.attribute(W, 'w')) - int(mar.attribute(W, 'left')) - int(mar.attribute(W, 'right'))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
styles = self.doc.part('StyleDefinitionsPart')
|
|
70
|
+
if styles is None: raise ValueError('reference doc lacks a styles relationship')
|
|
71
|
+
self.styles = styles.xml
|
|
72
|
+
numbering = part.xml if (part := self.doc.part('NumberingDefinitionsPart')) else None
|
|
83
73
|
used = [int(v) for e in ([] if numbering is None else numbering.root.children)
|
|
84
74
|
for a in ('numId', 'abstractNumId') if (v := e.attribute(W, a)) is not None]
|
|
85
75
|
self._numid = max(used, default=0)
|
|
86
76
|
for ref in reference[1:]: self._merge_styles(ref)
|
|
87
77
|
self._absbase = self._numid + 1
|
|
88
78
|
self._numid += 3 # reserve bullet, ordered-list, and heading abstract IDs after all contributors
|
|
89
|
-
self.refstyles = {
|
|
79
|
+
self.refstyles = {s.child(w.StyleName).val.lower(): s.style_id for s in self.styles.elements(w.Style)}
|
|
90
80
|
if missing := [n for n in STYLE_MAP.values() if n.lower() not in self.refstyles]:
|
|
91
81
|
raise ValueError(f'reference doc lacks dialect styles (map/template drift?): {missing}')
|
|
92
82
|
self.hlstyles = {n.removeprefix('hl ').replace(' ', '.'): sid for n, sid in self.refstyles.items() if n.startswith('hl ')}
|
|
@@ -96,20 +86,19 @@ class Converter:
|
|
|
96
86
|
self.scheme = list(number_headings.items()) if number_headings else None
|
|
97
87
|
self.headnum = None
|
|
98
88
|
heading = next((s for s in self.styles.elements(w.Style) if s.attribute(W, 'styleId') == 'Heading1'), None)
|
|
99
|
-
if number_headings and
|
|
89
|
+
if number_headings and next(heading.elements(w.NumberingProperties), None) is None:
|
|
100
90
|
self._numid += 1
|
|
101
91
|
self.headnum = self._numid
|
|
102
92
|
|
|
103
93
|
def _merge_styles(self, ref):
|
|
104
94
|
"Merge a contributor's w:style elements, later-wins on style id or name"
|
|
105
95
|
def keys(e):
|
|
106
|
-
nm =
|
|
96
|
+
nm = e.child(w.StyleName)
|
|
107
97
|
return {(e.attribute(W, 'styleId') or '').lower(), '' if nm is None else nm.attribute(W, 'val').lower()} - {''}
|
|
108
98
|
if str(ref).endswith('.docx'):
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
new = list(package.part(uri).xml.elements(w.Style))
|
|
99
|
+
styles = Document.open(ref).part('StyleDefinitionsPart')
|
|
100
|
+
if styles is None: raise ValueError(f'style contributor lacks a styles relationship: {ref}')
|
|
101
|
+
new = list(styles.xml.elements(w.Style))
|
|
113
102
|
elif str(ref).endswith('.xml'): new = self._xml_contrib(ref)
|
|
114
103
|
else: new = list(Tree(e.styles(theme_styles(ref)).bytes()).elements(w.Style))
|
|
115
104
|
existing = {key: s for s in self.styles.elements(w.Style) for key in keys(s)}
|
|
@@ -128,17 +117,15 @@ class Converter:
|
|
|
128
117
|
for tag in ('abstractNum', 'num'):
|
|
129
118
|
attr, mapping = f'{tag}Id', {}
|
|
130
119
|
for e in tree.root.children:
|
|
131
|
-
if e.
|
|
120
|
+
if e.qname != (W, tag): continue
|
|
132
121
|
self._numid += 1
|
|
133
122
|
mapping[e.attribute(W, attr)] = str(self._numid)
|
|
134
123
|
e.set_attribute(W, attr, str(self._numid))
|
|
135
124
|
for e in tree.elements():
|
|
136
|
-
if e.
|
|
125
|
+
if e.qname == (W, attr) and (val := e.attribute(W, 'val')) in mapping: e.set_attribute(W, 'val', mapping[val])
|
|
137
126
|
for e in tree.root.children:
|
|
138
|
-
if e.
|
|
139
|
-
|
|
140
|
-
before = {'num', 'numIdMacAtCleanup'} if e.raw['qname'] == (W, 'abstractNum') else {'numIdMacAtCleanup'}
|
|
141
|
-
e.copy_to(root, wpos(root, before))
|
|
127
|
+
if e.qname not in ((W, 'abstractNum'), (W, 'num')): continue
|
|
128
|
+
self._part('numbering').xml.root(e)
|
|
142
129
|
return list(tree.elements(w.Style))
|
|
143
130
|
|
|
144
131
|
def hlsid(self, scope):
|
|
@@ -148,24 +135,7 @@ class Converter:
|
|
|
148
135
|
if sid := self.hlstyles.get('.'.join(parts)): return sid
|
|
149
136
|
parts.pop()
|
|
150
137
|
|
|
151
|
-
def
|
|
152
|
-
"Add a new part without overwriting reference parts, including names differing only in case"
|
|
153
|
-
stem, ext = posixpath.splitext(uri)
|
|
154
|
-
n, candidate = 2, uri
|
|
155
|
-
while candidate.lower() in self._part_names: candidate, n = f'{stem}_{n}{ext}', n + 1
|
|
156
|
-
self.package.add_part(candidate, content_type, data)
|
|
157
|
-
self._part_names.add(candidate.lower())
|
|
158
|
-
return candidate
|
|
159
|
-
|
|
160
|
-
def _part(self, kind):
|
|
161
|
-
"A related Word part, creating an empty root and its relationship when absent."
|
|
162
|
-
if not (uri := self.part_uris[kind]):
|
|
163
|
-
uri = self._add_part(posixpath.join(posixpath.dirname(self.package.main_part), f'{kind}.xml'),
|
|
164
|
-
f'{WPML}.{kind}+xml', e(kind).bytes())
|
|
165
|
-
target = posixpath.relpath(uri, posixpath.dirname(self.package.main_part))
|
|
166
|
-
self.package.add_relationship(self.package.main_part, f'{R}/{kind}', target)
|
|
167
|
-
self.part_uris[kind] = uri
|
|
168
|
-
return self.package.part(uri)
|
|
138
|
+
def _part(self, kind): return self.doc.part(PARTS[kind], create=True)
|
|
169
139
|
|
|
170
140
|
def warn(self, msg): self.warnings.append(msg)
|
|
171
141
|
|
|
@@ -192,7 +162,7 @@ class Converter:
|
|
|
192
162
|
def field(self, instr, text, fmt):
|
|
193
163
|
"A simple Word field with cached text; every field requests an update on open."
|
|
194
164
|
self.has_fields = True
|
|
195
|
-
return
|
|
165
|
+
return field(instr, text, self.rpr(fmt))
|
|
196
166
|
|
|
197
167
|
def link(self, el, fmt):
|
|
198
168
|
"w:hyperlink for `a`: internal '#x' -> anchor, external -> relationship (deduped per URL); data-ref -> field"
|
|
@@ -205,19 +175,14 @@ class Converter:
|
|
|
205
175
|
if href.startswith('#'): return [e.hyperlink(runs, anchor=self.bkname(href[1:]))]
|
|
206
176
|
return self.external_link(href, runs)
|
|
207
177
|
|
|
208
|
-
def external_link(self, href, runs):
|
|
209
|
-
key = self._source_uri, href
|
|
210
|
-
if key not in self._urlrids:
|
|
211
|
-
self._urlrids[key] = self.package.add_relationship(self._source_uri, f'{R}/hyperlink', href, 'External')
|
|
212
|
-
return [e.hyperlink(runs, r__id=self._urlrids[key])]
|
|
178
|
+
def external_link(self, href, runs): return [self.doc.hyperlinks.link(href, runs, part_uri=self._source_uri)]
|
|
213
179
|
|
|
214
180
|
REFSWITCH = dict(full=r'\w', rel=r'\r', leaf=r'\n', text='', page=None)
|
|
215
181
|
|
|
216
182
|
def bkname(self, id):
|
|
217
183
|
"Word-legal bookmark name for `id` (letter first, word chars only), stable within the document"
|
|
218
184
|
if id not in self._bknames:
|
|
219
|
-
nm =
|
|
220
|
-
if not nm[:1].isalpha(): nm = 'B' + nm
|
|
185
|
+
nm = bookmark_name(id)
|
|
221
186
|
while nm in self._bknames.values(): nm += '_'
|
|
222
187
|
self._bknames[id] = nm
|
|
223
188
|
return self._bknames[id]
|
|
@@ -290,24 +255,15 @@ class Converter:
|
|
|
290
255
|
if key not in self.fndefs:
|
|
291
256
|
self.warn(f'footnote reference #{key} has no definition; dropped')
|
|
292
257
|
return []
|
|
293
|
-
if key not in self.fnids:
|
|
294
|
-
|
|
295
|
-
self.fnotes.append((self.fnids[key], self.fn_blocks(self.fndefs[key])))
|
|
296
|
-
return [e.r(e.rPr(e.rStyle(val=_sid('footnoteref'))), e.footnoteReference(id=self.fnids[key]))]
|
|
258
|
+
if key not in self.fnids: self.fnids[key] = self.doc.footnotes.create(self.fn_blocks(self.fndefs[key]))
|
|
259
|
+
return [self.fnids[key].reference()]
|
|
297
260
|
|
|
298
261
|
def fn_blocks(self, li):
|
|
299
|
-
"Footnote body with
|
|
262
|
+
"Footnote body blocks, built with the footnotes part as their relationship scope; oxml adds the reference mark"
|
|
300
263
|
save = self._source_uri, self.first
|
|
301
|
-
self._source_uri = self.
|
|
302
|
-
try:
|
|
264
|
+
self._source_uri = self.doc.part('FootnotesPart', create=True).uri
|
|
265
|
+
try: return self.blocks(li, 'footnotetext')
|
|
303
266
|
finally: self._source_uri, self.first = save
|
|
304
|
-
if not blks or blks[0].raw['qname'] != (W, 'p'): blks.insert(0, self.para([], 'footnotetext'))
|
|
305
|
-
p = blks[0]
|
|
306
|
-
ppr = wchild(p, 'pPr')
|
|
307
|
-
pos = p.raw['children'].index(ppr.node_id) + 1 if ppr is not None else 0
|
|
308
|
-
p(e.r(e.rPr(e.rStyle(val=_sid('footnoteref'))), e.footnoteRef()), index=pos)
|
|
309
|
-
p(e.r(e.t(' ', xml__space='preserve')), index=pos + 1)
|
|
310
|
-
return blks
|
|
311
267
|
|
|
312
268
|
def image(self, el, fmt, alt=None):
|
|
313
269
|
"Embed a local image (dimensions sniffed, width/height px attrs override); remote srcs degrade to a link"
|
|
@@ -320,20 +276,14 @@ class Converter:
|
|
|
320
276
|
except OSError:
|
|
321
277
|
self.warn(f'image not found: {src}; alt text emitted')
|
|
322
278
|
return self.text_runs(alt, fmt)
|
|
323
|
-
pw, ph, dx, dy = imgsize(data) or (300, 200, 96, 96)
|
|
324
|
-
cx, cy = round(pw * 914400 / dx), round(ph * 914400 / dy)
|
|
325
279
|
w_, h_ = el.attrs.get('width'), el.attrs.get('height')
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
uri = self._add_part(name, IMAGE_TYPES.get(ext[1:], 'application/octet-stream'), data)
|
|
334
|
-
target = posixpath.relpath(uri, posixpath.dirname(self._source_uri))
|
|
335
|
-
rid = self.package.add_relationship(self._source_uri, f'{R}/image', target)
|
|
336
|
-
return [e.r(drawing(rid, self._imgn, cx, cy, alt))]
|
|
280
|
+
try:
|
|
281
|
+
drawing = self.package.part(self._source_uri).add_image(data, width=emu(px=float(w_)) if w_ else None,
|
|
282
|
+
height=emu(px=float(h_)) if h_ else None, description=alt)
|
|
283
|
+
except ValueError as ex:
|
|
284
|
+
self.warn(f'{src}: {ex}; alt text emitted')
|
|
285
|
+
return self.text_runs(alt, fmt)
|
|
286
|
+
return [e.r(drawing)]
|
|
337
287
|
|
|
338
288
|
INLINE_FMT = {'em': {'i': True}, 'strong': {'b': True}, 'code': {'rstyle': _sid('codeinline')},
|
|
339
289
|
'del': {'strike': True}, 'mark': {'mark': True}, 'u': {'u': True}, 'sub': {'vert': 'subscript'}}
|
|
@@ -485,7 +435,7 @@ class Converter:
|
|
|
485
435
|
"Cell content ending in a paragraph; header cells bold, align honored for inline cells."
|
|
486
436
|
if any(c.name in BLOCK_TAGS for c in cell.element_children):
|
|
487
437
|
blocks = self.blocks(cell)
|
|
488
|
-
if not blocks or blocks[-1].
|
|
438
|
+
if not blocks or blocks[-1].qname != (W, 'p'): blocks.append(Tree(e.p().bytes()).root)
|
|
489
439
|
return blocks
|
|
490
440
|
jc = [e.jc(val=cell.attrs.get('align'))] if cell.attrs.get('align') in ('center', 'right') else None
|
|
491
441
|
return [self.para(self.runs(cell, {'b': True} if header else {}), 'compact', jc)]
|
|
@@ -528,7 +478,7 @@ class Converter:
|
|
|
528
478
|
gw = dxa or [self.content_w // ncols] * ncols # pandoc's docx reader drops tables whose gridCols lack w:w
|
|
529
479
|
grid = e.tblGrid(e.gridCol(w=gw[i]) for i in range(ncols))
|
|
530
480
|
def _trpr(header=False):
|
|
531
|
-
return e.trPr(e.cantSplit(val=
|
|
481
|
+
return e.trPr(e.cantSplit(val='off' if el.attrs.get('keep-rows') == 'false' else 'on'), e.tblHeader() if header else None)
|
|
532
482
|
def _marker_tr(mel):
|
|
533
483
|
"A range marker between rows: one full-width literal cell, so forms keep their markers visible"
|
|
534
484
|
tcpr = e.tcPr(_tcw(0, ncols), e.gridSpan(val=ncols) if ncols > 1 else None)
|
|
@@ -602,8 +552,7 @@ class Converter:
|
|
|
602
552
|
if tag == 'pre': return self.codeblock(el)
|
|
603
553
|
if tag in ('ul', 'ol'): return self.list_el(el)
|
|
604
554
|
if tag == 'table': return self.table(el)
|
|
605
|
-
if tag == 'hr':
|
|
606
|
-
return [Tree(e.p(e.pPr(e.pBdr(e.bottom(val='single', sz=6, space=1, color='auto')))).bytes()).root]
|
|
555
|
+
if tag == 'hr': return [Tree(e.p(e.pPr(e.pBdr(e.bottom(val='single', sz=6, space=1, color='auto')))).bytes()).root]
|
|
607
556
|
if tag == 'dl': return self.dl(el)
|
|
608
557
|
if tag == 'script': return self.rawxml(el)
|
|
609
558
|
if tag == 'figure': return self.figure(el)
|
|
@@ -741,12 +690,6 @@ class Converter:
|
|
|
741
690
|
fields = E('ns0', ns={'ns0': self.BIND_NS})
|
|
742
691
|
return fields.fields(fields(name) for name in self.bound).bytes()
|
|
743
692
|
|
|
744
|
-
def footnotes_xml(self):
|
|
745
|
-
"word/footnotes.xml: the two Word-required separator notes plus our harvested ones"
|
|
746
|
-
notes = [e.footnote(e.p(e.pPr(e.spacing(after=0)), e.r(e(typ))), type=typ, id=wid)
|
|
747
|
-
for typ, wid in (('separator', -1), ('continuationSeparator', 0))]
|
|
748
|
-
return _partxml.footnotes(notes, (e.footnote(blks, id=wid) for wid, blks in self.fnotes)).bytes()
|
|
749
|
-
|
|
750
693
|
def _number_heading_styles(self):
|
|
751
694
|
"Patch w:numPr into the Title and Heading1-5 styles, binding them to the generated heading numbering; the Title's level 0 is what restarts the count at every h1"
|
|
752
695
|
for i, sid in enumerate(HEADING_STYLE_IDS):
|
|
@@ -754,7 +697,7 @@ class Converter:
|
|
|
754
697
|
if st is None:
|
|
755
698
|
if i == 0: self.warn('no Title style in the reference: an h1 will not restart the heading numbering')
|
|
756
699
|
continue
|
|
757
|
-
ppr =
|
|
700
|
+
ppr = st.child(w.StyleParagraphProperties)
|
|
758
701
|
if ppr is None: ppr = st(e.pPr())
|
|
759
702
|
ppr(e.numPr(e.ilvl(val=i), e.numId(val=self.headnum)))
|
|
760
703
|
|
|
@@ -782,6 +725,7 @@ class Converter:
|
|
|
782
725
|
|
|
783
726
|
def to_docx(self, mdhtml, dest):
|
|
784
727
|
root = parse_frag(mdhtml)
|
|
728
|
+
for note in list(self.doc.footnotes): note.delete() # the reference supplies styles, not notes
|
|
785
729
|
nodes = self.harvest_footnotes(root.children)
|
|
786
730
|
self.idtext, self.reftarget, self.res = {}, {}, Resolver(self.reftypes)
|
|
787
731
|
for el in (el for node in nodes if isinstance(node, Element) for el in _walk(node)):
|
|
@@ -793,19 +737,12 @@ class Converter:
|
|
|
793
737
|
blocks = self.block_nodes(nodes)
|
|
794
738
|
self.package.replace_part(self.package.main_part, _partxml.document(e.body(blocks, self.sectpr)).bytes())
|
|
795
739
|
if self.nums or self.headnum: self._update_numbering()
|
|
796
|
-
if self.fnotes: self._part('footnotes').replace(self.footnotes_xml())
|
|
797
740
|
if self.bound: self.doc.set_custom_xml(self.BIND_ID, self.bind_item_xml(), schema_uri=self.BIND_NS)
|
|
798
741
|
self._update_styles()
|
|
799
|
-
if self.has_fields: self.
|
|
742
|
+
if self.has_fields: self.doc.settings['updateFields'] = True
|
|
800
743
|
self.doc.save(dest)
|
|
801
744
|
return self.warnings
|
|
802
745
|
|
|
803
|
-
def _update_settings(self):
|
|
804
|
-
"Set updateFields in the live settings part, so Word refreshes fields on open."
|
|
805
|
-
root = self._part('settings').xml.root
|
|
806
|
-
if (update := wchild(root, 'updateFields')) is not None: update.set_attribute(W, 'val', 'true')
|
|
807
|
-
else: root(e.updateFields(val='true'))
|
|
808
|
-
|
|
809
746
|
def mustache_fields(node):
|
|
810
747
|
"Template variables as live Word `MERGEFIELD`s (markers never reach `tmpl`: the converter shows them literally)"
|
|
811
748
|
return 'field', f'MERGEFIELD {node["value"]}'
|
|
@@ -54,11 +54,9 @@ def theme_styles(theme):
|
|
|
54
54
|
def theme_ref(theme, dest):
|
|
55
55
|
"Write a minimal, Word-openable reference docx at `dest` carrying only `theme`'s styles, for use as a later `reference` entry"
|
|
56
56
|
from oxml import Document
|
|
57
|
-
from .wml import
|
|
57
|
+
from .wml import e
|
|
58
58
|
document = Document.new()
|
|
59
59
|
document.main.replace(e.document(e.body(e.p())).bytes())
|
|
60
|
-
document.
|
|
61
|
-
e.styles(*theme_styles(theme)).bytes())
|
|
62
|
-
document.package.add_relationship(document.package.main_part, f'{R}/styles', 'styles.xml')
|
|
60
|
+
document.part('StyleDefinitionsPart', create=True).replace(e.styles(*theme_styles(theme)).bytes())
|
|
63
61
|
document.save(dest)
|
|
64
62
|
return dest
|
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""WordprocessingML construction helpers: namespaces, oxml expressions, unit
|
|
2
|
+
conversions, and the colwidths track-list parser. Builders emit children in the order callers pass
|
|
3
|
+
them; oxml sorts them into schema order when they are attached."""
|
|
4
|
+
import re
|
|
5
|
+
from oxml import E, e, namespace_uris
|
|
6
|
+
|
|
7
|
+
__all__ = ['W', 'R', 'WP', 'A', 'PIC', 'NS', 'E', 'e', 'twips', 'parse_tracks']
|
|
8
|
+
|
|
9
|
+
NS = {p: namespace_uris[p] for p in ('w', 'r', 'wp', 'a', 'pic', 'm', 'xml')}
|
|
10
|
+
W, R, WP, A, PIC = (NS[p] for p in ('w', 'r', 'wp', 'a', 'pic'))
|
|
11
|
+
|
|
12
|
+
# CSS length units in twips (1/20 pt). em/rem/ch use the template body size (11pt Aptos/Calibri).
|
|
13
|
+
UNITS = dict(pt=20, px=15, pc=240, em=220, rem=220, ch=110)
|
|
14
|
+
UNITS['in'] = 1440
|
|
15
|
+
UNITS['cm'] = 566.9
|
|
16
|
+
UNITS['mm'] = 56.69
|
|
17
|
+
|
|
18
|
+
def twips(s):
|
|
19
|
+
"CSS length string -> twips (int), e.g. '10em' -> 2200; raises ValueError on unknown units"
|
|
20
|
+
m = re.fullmatch(r'([\d.]+)\s*([a-z]+)', s.strip())
|
|
21
|
+
if not m or m[2] not in UNITS: raise ValueError(f'unsupported length: {s!r}')
|
|
22
|
+
return round(float(m[1]) * UNITS[m[2]])
|
|
23
|
+
|
|
24
|
+
def parse_tracks(s):
|
|
25
|
+
"""Parse a colwidths track list into (kind, value) pairs: ('dxa', twips) for lengths,
|
|
26
|
+
('fr', share) for fr units. E.g. '10em 3fr 7fr' -> [('dxa',2200),('fr',3.0),('fr',7.0)]"""
|
|
27
|
+
def _track(tok):
|
|
28
|
+
m = re.fullmatch(r'([\d.]+)fr', tok)
|
|
29
|
+
return ('fr', float(m[1])) if m else ('dxa', twips(tok))
|
|
30
|
+
return [_track(t) for t in s.split()]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdhtml2docx
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Convert MDHTML to Word docx files
|
|
5
5
|
Author-email: Jeremy Howard <j@fast.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -12,11 +12,9 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: mdhtml>=0.1.39
|
|
14
14
|
Requires-Dist: fast5ever>=0.1.5
|
|
15
|
-
Requires-Dist: oxml>=0.1.
|
|
16
|
-
Provides-Extra: validation
|
|
17
|
-
Requires-Dist: lxml; extra == "validation"
|
|
15
|
+
Requires-Dist: oxml>=0.1.4
|
|
18
16
|
Provides-Extra: dev
|
|
19
|
-
Requires-Dist:
|
|
17
|
+
Requires-Dist: lxml; extra == "dev"
|
|
20
18
|
Requires-Dist: pytest; extra == "dev"
|
|
21
19
|
Requires-Dist: fastship; extra == "dev"
|
|
22
20
|
Requires-Dist: build; extra == "dev"
|
|
@@ -175,3 +173,5 @@ Reference targets must be headings, paragraphs, figures, or tables with ids. A r
|
|
|
175
173
|
Independent schema validation is optional. Install `mdhtml2docx[validation]` to use `mdhtml2docx.validate.fast_checks(path)`; this extra requires lxml. Normal conversion does not import it.
|
|
176
174
|
|
|
177
175
|
The test suite checks docx containers, CRCs, and XML, validates against the ECMA-376 schemas with lxml, and performs semantic round trips through Pandoc's independent docx reader. Periodic acceptance runs open documents in Microsoft Word through AppleScript.
|
|
176
|
+
|
|
177
|
+
Live Word scripting is provided by [macscript](https://github.com/AnswerDotAI/macscript), not the converter. The development-only `tools/check_docx.py` combines `oxml` validation with a live Word acceptance check; see "Live Word checks" in [DEV.md](DEV.md). Normal installation and conversion do not require macscript or Office.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mdhtml2docx/__init__.py
|
|
5
|
+
mdhtml2docx/hilite.py
|
|
6
|
+
mdhtml2docx/mdhtml2docx.py
|
|
7
|
+
mdhtml2docx/styles.py
|
|
8
|
+
mdhtml2docx/wml.py
|
|
9
|
+
mdhtml2docx.egg-info/PKG-INFO
|
|
10
|
+
mdhtml2docx.egg-info/SOURCES.txt
|
|
11
|
+
mdhtml2docx.egg-info/dependency_links.txt
|
|
12
|
+
mdhtml2docx.egg-info/requires.txt
|
|
13
|
+
mdhtml2docx.egg-info/top_level.txt
|
|
14
|
+
mdhtml2docx/templates/reference.docx
|
|
15
|
+
tests/test_check_docx.py
|
|
16
|
+
tests/test_convert.py
|
|
@@ -15,12 +15,11 @@ classifiers = [
|
|
|
15
15
|
"Programming Language :: Python :: 3 :: Only",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
-
dependencies = ["mdhtml>=0.1.39", "fast5ever>=0.1.5", "oxml>=0.1.
|
|
18
|
+
dependencies = ["mdhtml>=0.1.39", "fast5ever>=0.1.5", "oxml>=0.1.4"]
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
|
-
validation = ["lxml"]
|
|
22
21
|
dev = [
|
|
23
|
-
"
|
|
22
|
+
"lxml",
|
|
24
23
|
"pytest",
|
|
25
24
|
"fastship",
|
|
26
25
|
"build",
|
|
@@ -40,7 +39,7 @@ include-package-data = false
|
|
|
40
39
|
include = ["mdhtml2docx"]
|
|
41
40
|
|
|
42
41
|
[tool.setuptools.package-data]
|
|
43
|
-
mdhtml2docx = ["
|
|
42
|
+
mdhtml2docx = ["templates/*"]
|
|
44
43
|
|
|
45
44
|
[tool.pytest.ini_options]
|
|
46
45
|
markers = ["slow: needs live GUI apps or long runtimes; deselected by default",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import sys, pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@pytest.mark.skipif(sys.platform != 'darwin', reason='Word scripting requires macOS')
|
|
5
|
+
def test_invalid_docx(tmp_path):
|
|
6
|
+
pytest.importorskip('macscript.word')
|
|
7
|
+
from tools.check_docx import check_docx
|
|
8
|
+
path = tmp_path/'invalid.docx'
|
|
9
|
+
path.write_bytes(b'not a docx')
|
|
10
|
+
status, details = check_docx(path)
|
|
11
|
+
assert status == 'invalid' and details
|