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.
Files changed (40) hide show
  1. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/PKG-INFO +5 -5
  2. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/README.md +2 -0
  3. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/__init__.py +2 -1
  4. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/hilite.py +1 -4
  5. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/mdhtml2docx.py +42 -105
  6. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx/styles.py +2 -4
  7. mdhtml2docx-0.1.5/mdhtml2docx/templates/reference.docx +0 -0
  8. mdhtml2docx-0.1.5/mdhtml2docx/wml.py +30 -0
  9. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/PKG-INFO +5 -5
  10. mdhtml2docx-0.1.5/mdhtml2docx.egg-info/SOURCES.txt +16 -0
  11. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/requires.txt +2 -5
  12. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/pyproject.toml +3 -4
  13. mdhtml2docx-0.1.5/tests/test_check_docx.py +11 -0
  14. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/tests/test_convert.py +181 -180
  15. mdhtml2docx-0.1.4/mdhtml2docx/asvocab.py +0 -91
  16. mdhtml2docx-0.1.4/mdhtml2docx/schemas/SOURCES.md +0 -7
  17. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-chart.xsd +0 -1499
  18. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-chartDrawing.xsd +0 -146
  19. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-diagram.xsd +0 -1085
  20. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-lockedCanvas.xsd +0 -11
  21. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-main.xsd +0 -3081
  22. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-picture.xsd +0 -23
  23. mdhtml2docx-0.1.4/mdhtml2docx/schemas/dml-wordprocessingDrawing.xsd +0 -287
  24. mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-commonSimpleTypes.xsd +0 -172
  25. mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-customXmlSchemaProperties.xsd +0 -18
  26. mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-math.xsd +0 -582
  27. mdhtml2docx-0.1.4/mdhtml2docx/schemas/shared-relationshipReference.xsd +0 -25
  28. mdhtml2docx-0.1.4/mdhtml2docx/schemas/wml.xsd +0 -3643
  29. mdhtml2docx-0.1.4/mdhtml2docx/schemas/xml.xsd +0 -287
  30. mdhtml2docx-0.1.4/mdhtml2docx/templates/reference.docx +0 -0
  31. mdhtml2docx-0.1.4/mdhtml2docx/validate.py +0 -42
  32. mdhtml2docx-0.1.4/mdhtml2docx/wml.py +0 -100
  33. mdhtml2docx-0.1.4/mdhtml2docx/word.py +0 -136
  34. mdhtml2docx-0.1.4/mdhtml2docx/word.sdef +0 -9661
  35. mdhtml2docx-0.1.4/mdhtml2docx.egg-info/SOURCES.txt +0 -34
  36. mdhtml2docx-0.1.4/tests/test_validate.py +0 -34
  37. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/LICENSE +0 -0
  38. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/dependency_links.txt +0 -0
  39. {mdhtml2docx-0.1.4 → mdhtml2docx-0.1.5}/mdhtml2docx.egg-info/top_level.txt +0 -0
  40. {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.4
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.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: mdhtml2docx[validation]; extra == "dev"
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,4 +1,5 @@
1
- __version__ = "0.1.4"
1
+ __version__ = "0.1.5"
2
+
2
3
 
3
4
 
4
5
 
@@ -1,7 +1,4 @@
1
- """Optional syntax scopes for code blocks, via fastpylight (Jeremy's tree-sitter highlighter).
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 posixpath, re
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
- def _related_part(package, kind, source=None):
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 -> footnote w: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 = wchild(wchild(self.doc.main.xml.root, 'body'), 'sectPr')
78
- pg, mar = wchild(self.sectpr, 'pgSz'), wchild(self.sectpr, 'pgMar')
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
- if not self.part_uris['styles']: raise ValueError('reference doc lacks a styles relationship')
81
- self.styles = self.package.part(self.part_uris['styles']).xml
82
- numbering = self.package.part(uri).xml if (uri := self.part_uris['numbering']) else None
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 = {wchild(s, 'name').attribute(W, 'val').lower(): s.attribute(W, 'styleId') for s in self.styles.elements(w.Style)}
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 wchild(wchild(heading, 'pPr'), 'numPr') is None:
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 = wchild(e, 'name')
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
- package = Document.open(ref).package
110
- uri = _related_part(package, 'styles')
111
- if uri is None: raise ValueError(f'style contributor lacks a styles relationship: {ref}')
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.raw['qname'] != (W, tag): continue
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.raw['qname'] == (W, attr) and (val := e.attribute(W, 'val')) in mapping: e.set_attribute(W, 'val', mapping[val])
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.raw['qname'] not in ((W, 'abstractNum'), (W, 'num')): continue
139
- root = self._part('numbering').xml.root
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 _add_part(self, uri, content_type, data):
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 e.fldSimple(e.r(self.rpr(fmt), e.t(text)), instr=f' {instr.strip()} ')
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 = re.sub(r'\W', '_', id)
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
- self.fnids[key] = len(self.fnids) + 1
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 its reference mark, in footnote-text style, backref stripped."
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._part('footnotes').uri
302
- try: blks = self.blocks(li, 'footnotetext')
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
- if w_: cx = round(float(w_) * EMU_PER_PX)
327
- if h_: cy = round(float(h_) * EMU_PER_PX)
328
- if w_ and not h_: cy = round(cx * ph / pw)
329
- if h_ and not w_: cx = round(cy * pw / ph)
330
- self._imgn += 1
331
- ext = Path(src).suffix.lower() or '.bin'
332
- name = posixpath.join(posixpath.dirname(self.package.main_part), f'media/image{self._imgn}{ext}')
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].raw['qname'] != (W, 'p'): blocks.append(Tree(e.p().bytes()).root)
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=int(el.attrs.get('keep-rows') != 'false')), e.tblHeader() if header else None)
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 = wchild(st, '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._update_settings()
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 R, e
57
+ from .wml import e
58
58
  document = Document.new()
59
59
  document.main.replace(e.document(e.body(e.p())).bytes())
60
- document.package.add_part('/word/styles.xml', 'application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml',
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
@@ -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.4
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.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: mdhtml2docx[validation]; extra == "dev"
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
@@ -1,13 +1,10 @@
1
1
  mdhtml>=0.1.39
2
2
  fast5ever>=0.1.5
3
- oxml>=0.1.1
3
+ oxml>=0.1.4
4
4
 
5
5
  [dev]
6
- mdhtml2docx[validation]
6
+ lxml
7
7
  pytest
8
8
  fastship
9
9
  build
10
10
  twine
11
-
12
- [validation]
13
- lxml
@@ -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.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
- "mdhtml2docx[validation]",
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 = ["schemas/*", "templates/*", "word.sdef"]
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