mdhtml2docx 0.1.0__tar.gz → 0.1.2__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.0 → mdhtml2docx-0.1.2}/CHANGELOG.md +16 -1
- {mdhtml2docx-0.1.0/mdhtml2docx.egg-info → mdhtml2docx-0.1.2}/PKG-INFO +3 -3
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/README.md +1 -1
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/__init__.py +3 -1
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/convert.py +58 -50
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2/mdhtml2docx.egg-info}/PKG-INFO +3 -3
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/requires.txt +1 -1
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/pyproject.toml +4 -3
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/tests/test_convert.py +47 -21
- mdhtml2docx-0.1.2/tests/test_word.py +17 -0
- mdhtml2docx-0.1.0/tests/test_word.py +0 -16
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/LICENSE +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/MANIFEST.in +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/asvocab.py +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/hilite.py +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/SOURCES.md +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-chart.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-chartDrawing.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-diagram.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-lockedCanvas.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-main.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-picture.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-wordprocessingDrawing.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-commonSimpleTypes.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-customXmlSchemaProperties.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-math.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-relationshipReference.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/wml.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/xml.xsd +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/styles.py +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/templates/reference.docx +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/validate.py +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/wml.py +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/word.py +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/word.sdef +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/SOURCES.txt +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/dependency_links.txt +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/top_level.txt +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/setup.cfg +0 -0
- {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/tests/test_validate.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
<!-- do not remove -->
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
- Support cross-references to text targets (spans, definition terms) via shared mdhtml Resolver; bookmark span and dt elements ([#10](https://github.com/AnswerDotAI/mdhtml2docx/issues/10))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## 0.1.1
|
|
11
|
+
|
|
12
|
+
### New Features
|
|
13
|
+
|
|
14
|
+
- Move template marker rendering into converter as literal «body» runs; tmpl callable now takes token node dict ([#8](https://github.com/AnswerDotAI/mdhtml2docx/issues/8))
|
|
15
|
+
- Move `jinja_literal` to mdhtml.jinja and import `mustache_kind` from mdhtml.mustache ([#7](https://github.com/AnswerDotAI/mdhtml2docx/issues/7))
|
|
16
|
+
- Add details-div degradation to bold label and switch dash handling to explicit replacements callback ([#6](https://github.com/AnswerDotAI/mdhtml2docx/issues/6))
|
|
17
|
+
|
|
18
|
+
|
|
3
19
|
## 0.1.0
|
|
4
20
|
|
|
5
21
|
### New Features
|
|
@@ -8,4 +24,3 @@
|
|
|
8
24
|
- Rename to mdhtml2docx and switch input from xhtmlmd XHTML to mdhtml JustHTML DOM with support for raw-data encodings, reference tokens/groups, HTML5 repair, inert templates, and figcaption alt text ([#2](https://github.com/AnswerDotAI/mdhtml2docx/issues/2))
|
|
9
25
|
- Add xhtml2docx conversion engine, WML helpers, style management, validation, Word scripting, and syntax highlighting modules ([#1](https://github.com/AnswerDotAI/mdhtml2docx/issues/1))
|
|
10
26
|
- Support MDHTML raw-data encodings, reference tokens and groups, HTML5 body repair, inert templates, and figure accessibility text from `figcaption`.
|
|
11
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdhtml2docx
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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
|
|
@@ -11,7 +11,7 @@ Requires-Python: >=3.10
|
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: lxml
|
|
14
|
-
Requires-Dist: mdhtml>=0.1.
|
|
14
|
+
Requires-Dist: mdhtml>=0.1.32
|
|
15
15
|
Requires-Dist: fast5ever>=0.1.1
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: fastship; extra == "dev"
|
|
@@ -49,7 +49,7 @@ warnings = convert(document, 'out.docx')
|
|
|
49
49
|
|
|
50
50
|
Body-position text and phrasing content become implicit Word paragraphs. Whitespace between blocks remains inert. HTML templates remain inert and are not rendered.
|
|
51
51
|
|
|
52
|
-
Supported: headings, paragraphs, bold, italic, strikethrough, underline (the `u` element), highlight, superscript and subscript, inline code, links (external and internal, via bookmarks), images (local files are embedded with correct dimensions; remote URLs degrade to links), block quotes, fenced and indented code blocks, bullet and numbered lists (including nesting, `start`, and task-list checkboxes), pipe and grid tables (including row and column spans and header rows), definition lists, footnotes, horizontal rules, and abbreviations. Math spans and blocks become native Word math zones (`m:oMath`) holding the source text as-is, whatever the dialect (TeX, UnicodeMath, AsciiMath). Build-up/rendering is left downstream, and pandoc reads the zones back as math.
|
|
52
|
+
Supported: headings, paragraphs, bold, italic, strikethrough, underline (the `u` element), highlight, superscript and subscript, inline code, links (external and internal, via bookmarks), images (local files are embedded with correct dimensions; remote URLs degrade to links), block quotes, fenced and indented code blocks, bullet and numbered lists (including nesting, `start`, and task-list checkboxes), pipe and grid tables (including row and column spans and header rows), definition lists, footnotes, horizontal rules, and abbreviations. A `details` div (the dialect's collapsible block) degrades to its label as a bold line above the body, never a numbered heading — Word has no folding. Math spans and blocks become native Word math zones (`m:oMath`) holding the source text as-is, whatever the dialect (TeX, UnicodeMath, AsciiMath). Build-up/rendering is left downstream, and pandoc reads the zones back as math.
|
|
53
53
|
|
|
54
54
|
Code blocks with a language are syntax colored when [fastpylight](https://github.com/AnswerDotAI/fastpylight) is installed: tokens get `Hl*` character styles (e.g. `Hl Keyword`), whose colors come from the theme entry in the reference list below, so a finished document can be restyled in Word like everything else.
|
|
55
55
|
|
|
@@ -28,7 +28,7 @@ warnings = convert(document, 'out.docx')
|
|
|
28
28
|
|
|
29
29
|
Body-position text and phrasing content become implicit Word paragraphs. Whitespace between blocks remains inert. HTML templates remain inert and are not rendered.
|
|
30
30
|
|
|
31
|
-
Supported: headings, paragraphs, bold, italic, strikethrough, underline (the `u` element), highlight, superscript and subscript, inline code, links (external and internal, via bookmarks), images (local files are embedded with correct dimensions; remote URLs degrade to links), block quotes, fenced and indented code blocks, bullet and numbered lists (including nesting, `start`, and task-list checkboxes), pipe and grid tables (including row and column spans and header rows), definition lists, footnotes, horizontal rules, and abbreviations. Math spans and blocks become native Word math zones (`m:oMath`) holding the source text as-is, whatever the dialect (TeX, UnicodeMath, AsciiMath). Build-up/rendering is left downstream, and pandoc reads the zones back as math.
|
|
31
|
+
Supported: headings, paragraphs, bold, italic, strikethrough, underline (the `u` element), highlight, superscript and subscript, inline code, links (external and internal, via bookmarks), images (local files are embedded with correct dimensions; remote URLs degrade to links), block quotes, fenced and indented code blocks, bullet and numbered lists (including nesting, `start`, and task-list checkboxes), pipe and grid tables (including row and column spans and header rows), definition lists, footnotes, horizontal rules, and abbreviations. A `details` div (the dialect's collapsible block) degrades to its label as a bold line above the body, never a numbered heading — Word has no folding. Math spans and blocks become native Word math zones (`m:oMath`) holding the source text as-is, whatever the dialect (TeX, UnicodeMath, AsciiMath). Build-up/rendering is left downstream, and pandoc reads the zones back as math.
|
|
32
32
|
|
|
33
33
|
Code blocks with a language are syntax colored when [fastpylight](https://github.com/AnswerDotAI/fastpylight) is installed: tokens get `Hl*` character styles (e.g. `Hl Keyword`), whose colors come from the theme entry in the reference list below, so a finished document can be restyled in Word like everything else.
|
|
34
34
|
|
|
@@ -10,14 +10,14 @@ from pathlib import Path
|
|
|
10
10
|
from fast5ever import Comment, Element, Node, Text
|
|
11
11
|
from lxml import etree
|
|
12
12
|
from mdhtml import parse_mdhtml
|
|
13
|
-
from mdhtml.export import REFTYPES, SCHEMES, decode_raw,
|
|
13
|
+
from mdhtml.export import REFTYPES, SCHEMES, decode_raw, tmpl_node, group_plan, ref_tokens, ref_variant, target_kind, Resolver
|
|
14
14
|
from .styles import STYLE_MAP, style_id, theme_styles
|
|
15
15
|
from .styles import ref_path as _refpath
|
|
16
16
|
from .wml import *
|
|
17
17
|
from .wml import qn
|
|
18
18
|
from .hilite import segments, tokenize
|
|
19
19
|
|
|
20
|
-
__all__ = ['convert', 'mustache_fields'
|
|
20
|
+
__all__ = ['convert', 'mustache_fields']
|
|
21
21
|
|
|
22
22
|
def _sid(key): return style_id(STYLE_MAP[key])
|
|
23
23
|
|
|
@@ -195,34 +195,30 @@ class Converter:
|
|
|
195
195
|
return self._bknames[id]
|
|
196
196
|
|
|
197
197
|
def ref_prefix(self, el, fmt, plural=False):
|
|
198
|
-
"Literal runs before a reference field: override text, the type
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if 'bare' in ref_tokens(_get(el, 'data-ref')) or self.reftarget.get(tgt) == 'caption': return []
|
|
203
|
-
t = tgt.split('-')[0]
|
|
204
|
-
if t not in self.reftypes: raise ValueError(f'unknown reference type {t!r}; pass reftypes= to define its prefix')
|
|
205
|
-
pre = self.reftypes[t][plural]
|
|
206
|
-
return self.text_runs(pre + ' ', fmt)
|
|
198
|
+
"Literal runs before a reference field: the shared `Resolver.prefix` (override text, the type word, or nothing for bare, caption, and text refs)"
|
|
199
|
+
tgt = (_get(el, 'href') or '#')[1:]
|
|
200
|
+
pre = self.res.prefix(el.to_text().strip(), tgt, ref_tokens(_get(el, 'data-ref')), plural)
|
|
201
|
+
return self.text_runs(pre, fmt) if pre else []
|
|
207
202
|
|
|
208
203
|
def ref_fld(self, el, fmt):
|
|
209
204
|
"""REF/PAGEREF field for a cross-reference `a`, with a cached placeholder Word replaces on update.
|
|
210
205
|
Heading/paragraph targets number via `\\w`; caption targets return their bookmarked 'Label N' text
|
|
211
|
-
(or the number-only `_n` bookmark for bare/leaf/rel refs),
|
|
206
|
+
(or the number-only `_n` bookmark for bare/leaf/rel refs), and text targets (spans, definition
|
|
207
|
+
terms) their bookmark text, so `\\w` never applies to either."""
|
|
212
208
|
tgt = (_get(el, 'href') or '#')[1:]
|
|
213
209
|
tokens = ref_tokens(_get(el, 'data-ref'))
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
kind = ref_variant(tokens)
|
|
210
|
+
self.res.check(tgt)
|
|
211
|
+
variant = ref_variant(tokens)
|
|
217
212
|
nm, self.has_fields = self.bkname(tgt), True
|
|
218
|
-
if
|
|
213
|
+
if variant == 'page': instr, cached = rf' PAGEREF {nm} \h ', '#'
|
|
219
214
|
elif self.reftarget[tgt] == 'caption':
|
|
220
|
-
bare = 'bare' in tokens or
|
|
215
|
+
bare = 'bare' in tokens or variant in ('leaf', 'rel')
|
|
221
216
|
instr, cached = (rf' REF {nm}_n \h ' if bare else rf' REF {nm} \h '), '#'
|
|
217
|
+
elif self.reftarget[tgt] == 'text': instr, cached = rf' REF {nm} \h ', self.res.core(tgt, tokens)
|
|
222
218
|
else:
|
|
223
|
-
sw = self.REFSWITCH[
|
|
219
|
+
sw = self.REFSWITCH[variant]
|
|
224
220
|
instr = rf' REF {nm} {sw} \h ' if sw else rf' REF {nm} \h '
|
|
225
|
-
cached = self.idtext.get(tgt, '#') if
|
|
221
|
+
cached = self.idtext.get(tgt, '#') if variant == 'text' else '#'
|
|
226
222
|
return [E('w:fldSimple', {'w:instr': instr}, E('w:r', self.rpr(fmt), E('w:t', cached)))]
|
|
227
223
|
|
|
228
224
|
def ref_group(self, el, fmt):
|
|
@@ -247,11 +243,11 @@ class Converter:
|
|
|
247
243
|
return next((self.refstyles[c.lower()] for c in _classes(el) if c.lower() in self.refstyles), None)
|
|
248
244
|
|
|
249
245
|
def span(self, el, fmt):
|
|
250
|
-
"Inline span: math -> inline m:oMath zone (linear source, dialect-agnostic), custom style -> rStyle, else transparent"
|
|
246
|
+
"Inline span: math -> inline m:oMath zone (linear source, dialect-agnostic), custom style -> rStyle, else transparent; an id bookmarks the runs"
|
|
251
247
|
if _get(el, 'data-refs') is not None: return self.ref_group(el, fmt)
|
|
252
|
-
if 'math' in _classes(el): return [self.omath(el)]
|
|
253
|
-
if sid := self.custom_style(el, 'character'): return self.runs(el, fmt | {'rstyle': sid})
|
|
254
|
-
return self.runs(el, fmt)
|
|
248
|
+
if 'math' in _classes(el): return self.bookmark(el, [self.omath(el)])
|
|
249
|
+
if sid := self.custom_style(el, 'character'): return self.bookmark(el, self.runs(el, fmt | {'rstyle': sid}))
|
|
250
|
+
return self.bookmark(el, self.runs(el, fmt))
|
|
255
251
|
|
|
256
252
|
def omath(self, el):
|
|
257
253
|
"An m:oMath zone holding `el`'s text as linear-format math runs"
|
|
@@ -469,15 +465,20 @@ class Converter:
|
|
|
469
465
|
def table(self, el):
|
|
470
466
|
"w:tbl (+ caption paragraph before, spacer paragraph after)"
|
|
471
467
|
cap = None
|
|
472
|
-
rows, nhead = [], 0
|
|
468
|
+
rows, nhead, markers = [], 0, {}
|
|
469
|
+
def _add(c):
|
|
470
|
+
"Rows in order; template markers recorded at the row index they precede"
|
|
471
|
+
if _tag(c) == 'template': markers.setdefault(len(rows), []).append(c)
|
|
472
|
+
else: rows.append(c)
|
|
473
473
|
for sec in _els(el):
|
|
474
474
|
t = _tag(sec)
|
|
475
475
|
if t == 'caption': cap = sec
|
|
476
476
|
elif t == 'thead':
|
|
477
|
-
|
|
477
|
+
for c in _els(sec): _add(c)
|
|
478
478
|
nhead = len(rows)
|
|
479
|
-
elif t in ('tbody', 'tfoot'):
|
|
480
|
-
|
|
479
|
+
elif t in ('tbody', 'tfoot'):
|
|
480
|
+
for c in _els(sec): _add(c)
|
|
481
|
+
elif t in ('tr', 'template'): _add(sec)
|
|
481
482
|
placed, ncols = self.table_grid(rows)
|
|
482
483
|
dxa, all_fr = self.col_widths(el, ncols)
|
|
483
484
|
def _tcw(ci, cs):
|
|
@@ -494,8 +495,13 @@ class Converter:
|
|
|
494
495
|
'w:firstColumn': 0, 'w:lastColumn': 0, 'w:noHBand': 0, 'w:noVBand': 1}))
|
|
495
496
|
gw = dxa or [self.content_w // ncols] * ncols # pandoc's docx reader drops tables whose gridCols lack w:w
|
|
496
497
|
grid = E('w:tblGrid', *[E('w:gridCol', {'w:w': gw[i]}) for i in range(ncols)])
|
|
498
|
+
def _marker_tr(mel):
|
|
499
|
+
"A range marker between rows: one full-width literal cell, so forms keep their markers visible"
|
|
500
|
+
tcpr = E('w:tcPr', _tcw(0, ncols), E('w:gridSpan', {'w:val': ncols}) if ncols > 1 else None)
|
|
501
|
+
return E('w:tr', E('w:tc', tcpr, self.para(self.tmpl_runs(mel, {}, 'row'))))
|
|
497
502
|
trs = []
|
|
498
503
|
for ri, rowcells in enumerate(placed):
|
|
504
|
+
for mel in markers.get(ri, []): trs.append(_marker_tr(mel))
|
|
499
505
|
tcs = []
|
|
500
506
|
for item in rowcells:
|
|
501
507
|
if item[0] == 'cont':
|
|
@@ -512,6 +518,7 @@ class Converter:
|
|
|
512
518
|
if not len(body) or etree.QName(body[-1]).localname != 'p': body.append(E('w:p'))
|
|
513
519
|
tcs.append(E('w:tc', tcpr, *body))
|
|
514
520
|
trs.append(E('w:tr', E('w:trPr', E('w:tblHeader')) if ri < nhead else None, *tcs))
|
|
521
|
+
for mel in markers.get(len(rows), []): trs.append(_marker_tr(mel))
|
|
515
522
|
out = self.caption_para(el, 'tbl', cap)
|
|
516
523
|
return out + [E('w:tbl', tblpr, grid, *trs), E('w:p')]
|
|
517
524
|
|
|
@@ -582,6 +589,9 @@ class Converter:
|
|
|
582
589
|
if tag == 'div':
|
|
583
590
|
cls = _classes(el)
|
|
584
591
|
if 'math' in cls and 'display' in cls: return [E('w:p', E('m:oMathPara', self.omath(el)))]
|
|
592
|
+
if 'details' in cls and (kids := _els(el)) and _tag(kids[0]) in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
|
|
593
|
+
label = self.para(self.runs(kids[0], {'b': True}), style) # the dialect's collapsible block: label as a bold line, never a numbered heading
|
|
594
|
+
return [label, *self.block_nodes(kids[1:], style, sid)]
|
|
585
595
|
return self.blocks(el, style, self.custom_style(el, 'paragraph') or sid)
|
|
586
596
|
if tag in BLOCK_TAGS and any(_tag(c) in BLOCK_TAGS for c in _els(el)):
|
|
587
597
|
return self.blocks(el, style, sid) # unknown container: recurse
|
|
@@ -594,16 +604,21 @@ class Converter:
|
|
|
594
604
|
BIND_ID = '{8E2C9A44-7D31-4E5B-9C0D-1A6F2B3C4D5E}' # fixed datastore id, so builds are reproducible
|
|
595
605
|
|
|
596
606
|
def tmpl_runs(self, el, fmt, form):
|
|
597
|
-
"""Template-token runs
|
|
598
|
-
|
|
607
|
+
"""Template-token runs. Range markers and unknown tokens are converter policy: literal
|
|
608
|
+
`«body»` runs, so an unfilled form shows its markers. Var tokens go through the `tmpl`
|
|
609
|
+
callable with the token node dict (see `mdhtml.export.tmpl_node`): str is a literal text
|
|
610
|
+
run, ('field', instr) a live field, ('control', name) an interactive plain-text content
|
|
611
|
+
control, ('bound', name) a data-bound one, None dropped"""
|
|
612
|
+
node = tmpl_node(el, form)
|
|
613
|
+
if node["kind"] != "var": return self.text_runs(f'«{node["body"].strip()}»', fmt)
|
|
599
614
|
if self.tmpl is None: return []
|
|
600
|
-
res = self.tmpl(
|
|
615
|
+
res = self.tmpl(node)
|
|
601
616
|
if res is None: return []
|
|
602
617
|
if isinstance(res, str): return self.text_runs(res, fmt)
|
|
603
618
|
kind, val = res
|
|
604
619
|
if kind == 'field':
|
|
605
620
|
self.has_fields = True
|
|
606
|
-
return [E('w:fldSimple', {'w:instr': f' {val.strip()} '}, E('w:r', self.rpr(fmt), E('w:t', f'«{
|
|
621
|
+
return [E('w:fldSimple', {'w:instr': f' {val.strip()} '}, E('w:r', self.rpr(fmt), E('w:t', f'«{node["name"]}»')))]
|
|
607
622
|
if kind in ('control', 'bound'):
|
|
608
623
|
self.has_controls = True
|
|
609
624
|
sdtpr = E('w:sdtPr', E('w:alias', {'w:val': val}), E('w:tag', {'w:val': val}), E('w:showingPlcHdr'))
|
|
@@ -634,7 +649,7 @@ class Converter:
|
|
|
634
649
|
out = []
|
|
635
650
|
for c in _els(el):
|
|
636
651
|
t = _tag(c)
|
|
637
|
-
if t == 'dt': out.append(self.para(self.runs(c, {}), 'dt'))
|
|
652
|
+
if t == 'dt': out.append(self.para(self.bookmark(c, self.runs(c, {})), 'dt'))
|
|
638
653
|
elif t == 'dd':
|
|
639
654
|
blocky = any(_tag(k) in BLOCK_TAGS for k in _els(c))
|
|
640
655
|
out += self.blocks(c, 'dd') if blocky else [self.para(self.runs(c, {}), 'dd')]
|
|
@@ -832,18 +847,16 @@ class Converter:
|
|
|
832
847
|
self.fndefs.update({_get(li, 'id'): li for sec in fn for li in _walk(sec) if _tag(li) == 'li' and _get(li, 'id')})
|
|
833
848
|
return body
|
|
834
849
|
|
|
835
|
-
BOOKMARKABLE = {'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
|
|
836
|
-
|
|
837
850
|
def to_docx(self, mdhtml, dest):
|
|
838
851
|
root = parse_frag(mdhtml)
|
|
839
852
|
nodes = self.harvest_footnotes(root.children)
|
|
840
|
-
self.idtext, self.reftarget = {}, {}
|
|
853
|
+
self.idtext, self.reftarget, self.res = {}, {}, Resolver(self.reftypes)
|
|
841
854
|
for e in (e for node in nodes if isinstance(node, Element) for e in _walk(node)):
|
|
842
855
|
if not (i := _get(e, 'id')): continue
|
|
843
856
|
self.idtext[i] = ' '.join(e.to_text().split())
|
|
844
|
-
|
|
845
|
-
if
|
|
846
|
-
|
|
857
|
+
k = target_kind(_tag(e))
|
|
858
|
+
if k: self.reftarget[i] = k
|
|
859
|
+
self.res.register(i, k, self.idtext[i])
|
|
847
860
|
self.ids = set(self.idtext)
|
|
848
861
|
blocks = self.block_nodes(nodes)
|
|
849
862
|
docxml = self.document(blocks)
|
|
@@ -888,16 +901,10 @@ class Converter:
|
|
|
888
901
|
root.insert(pos, E('w:updateFields', {'w:val': 'true'}))
|
|
889
902
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
890
903
|
|
|
891
|
-
def mustache_fields(
|
|
892
|
-
"
|
|
893
|
-
|
|
894
|
-
return 'field', f'MERGEFIELD {body.strip()}'
|
|
895
|
-
|
|
904
|
+
def mustache_fields(node):
|
|
905
|
+
"Template variables as live Word `MERGEFIELD`s (markers never reach `tmpl`: the converter shows them literally)"
|
|
906
|
+
return 'field', f'MERGEFIELD {node["name"]}'
|
|
896
907
|
|
|
897
|
-
def jinja_literal(body, syntax, form):
|
|
898
|
-
"Jinja tokens re-spelled canonically (`{{ x }}`/`{% x %}`) as text, for docxtpl-style downstream pipelines"
|
|
899
|
-
o, c = ('{%', '%}') if syntax == 'jinja-stmt' else ('{{', '}}')
|
|
900
|
-
return f'{o} {body.strip()} {c}'
|
|
901
908
|
|
|
902
909
|
|
|
903
910
|
def convert(mdhtml, dest, reference=None, base=None, reftypes=None, number_headings=None, tmpl=None):
|
|
@@ -911,9 +918,10 @@ def convert(mdhtml, dest, reference=None, base=None, reftypes=None, number_headi
|
|
|
911
918
|
live REF fields; `reftypes` maps type tokens to (singular, plural) prefix words beyond the built-in
|
|
912
919
|
`sec`, and `number_headings` (a styles.SCHEMES name such as 'legal', or a {lvlText: numFmt} dict, one entry per heading level)
|
|
913
920
|
numbers the headings via a multilevel list so `\\w` fields resolve. Template tokens are dropped
|
|
914
|
-
unless `tmpl` is given: a callable
|
|
921
|
+
unless `tmpl` is given: a callable taking the token node dict (`mdhtml.export.tmpl_node`: `body`,
|
|
922
|
+
`syntax`, `form`, `kind`, `name`, `inverted`) and returning a str for a literal text run,
|
|
915
923
|
`('field', instr)` for a live field, `('control', name)` for an interactive plain-text content
|
|
916
924
|
control, `('bound', name)` for a content control data-bound to a shared per-variable XML node
|
|
917
|
-
(same-name controls stay in sync as one is filled), or None to drop - `mustache_fields`
|
|
918
|
-
|
|
925
|
+
(same-name controls stay in sync as one is filled), or None to drop; range markers never reach `tmpl` and render as literal «body» runs - `mustache_fields` here
|
|
926
|
+
is the ready-made recipe."""
|
|
919
927
|
return Converter(reference, base, reftypes, number_headings, tmpl).to_docx(mdhtml, dest)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdhtml2docx
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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
|
|
@@ -11,7 +11,7 @@ Requires-Python: >=3.10
|
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: lxml
|
|
14
|
-
Requires-Dist: mdhtml>=0.1.
|
|
14
|
+
Requires-Dist: mdhtml>=0.1.32
|
|
15
15
|
Requires-Dist: fast5ever>=0.1.1
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: fastship; extra == "dev"
|
|
@@ -49,7 +49,7 @@ warnings = convert(document, 'out.docx')
|
|
|
49
49
|
|
|
50
50
|
Body-position text and phrasing content become implicit Word paragraphs. Whitespace between blocks remains inert. HTML templates remain inert and are not rendered.
|
|
51
51
|
|
|
52
|
-
Supported: headings, paragraphs, bold, italic, strikethrough, underline (the `u` element), highlight, superscript and subscript, inline code, links (external and internal, via bookmarks), images (local files are embedded with correct dimensions; remote URLs degrade to links), block quotes, fenced and indented code blocks, bullet and numbered lists (including nesting, `start`, and task-list checkboxes), pipe and grid tables (including row and column spans and header rows), definition lists, footnotes, horizontal rules, and abbreviations. Math spans and blocks become native Word math zones (`m:oMath`) holding the source text as-is, whatever the dialect (TeX, UnicodeMath, AsciiMath). Build-up/rendering is left downstream, and pandoc reads the zones back as math.
|
|
52
|
+
Supported: headings, paragraphs, bold, italic, strikethrough, underline (the `u` element), highlight, superscript and subscript, inline code, links (external and internal, via bookmarks), images (local files are embedded with correct dimensions; remote URLs degrade to links), block quotes, fenced and indented code blocks, bullet and numbered lists (including nesting, `start`, and task-list checkboxes), pipe and grid tables (including row and column spans and header rows), definition lists, footnotes, horizontal rules, and abbreviations. A `details` div (the dialect's collapsible block) degrades to its label as a bold line above the body, never a numbered heading — Word has no folding. Math spans and blocks become native Word math zones (`m:oMath`) holding the source text as-is, whatever the dialect (TeX, UnicodeMath, AsciiMath). Build-up/rendering is left downstream, and pandoc reads the zones back as math.
|
|
53
53
|
|
|
54
54
|
Code blocks with a language are syntax colored when [fastpylight](https://github.com/AnswerDotAI/fastpylight) is installed: tokens get `Hl*` character styles (e.g. `Hl Keyword`), whose colors come from the theme entry in the reference list below, so a finished document can be restyled in Word like everything else.
|
|
55
55
|
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
"Programming Language :: Python :: 3 :: Only",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
-
dependencies = ["lxml", "mdhtml>=0.1.
|
|
18
|
+
dependencies = ["lxml", "mdhtml>=0.1.32", "fast5ever>=0.1.1"]
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
21
|
dev = [
|
|
@@ -40,5 +40,6 @@ include = ["mdhtml2docx"]
|
|
|
40
40
|
mdhtml2docx = ["schemas/*", "templates/*", "word.sdef"]
|
|
41
41
|
|
|
42
42
|
[tool.pytest.ini_options]
|
|
43
|
-
markers = ["slow: needs live GUI apps or long runtimes; deselected by default"
|
|
44
|
-
|
|
43
|
+
markers = ["slow: needs live GUI apps or long runtimes; deselected by default",
|
|
44
|
+
"checkout: needs the mdhtml source tree (examples/sample.md), not just the wheel; deselected by default"]
|
|
45
|
+
addopts = "-m 'not slow and not checkout'"
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import base64, subprocess, zipfile
|
|
1
|
+
import base64, pytest, subprocess, zipfile
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from fastcore.test import test_eq as teq, test as tt, test_fail as tfail
|
|
5
5
|
from fastcore.utils import in_
|
|
6
6
|
|
|
7
|
-
from mdhtml import
|
|
8
|
-
from
|
|
7
|
+
from mdhtml import DASHES, replacements, parse_mdhtml, to_mdhtml
|
|
8
|
+
from mdhtml.mustache import MUSTACHE
|
|
9
|
+
from mdhtml.tools import SAMPLE_MD, sample_md
|
|
10
|
+
from mdhtml2docx.convert import convert, mustache_fields
|
|
9
11
|
from mdhtml2docx.validate import fast_checks
|
|
10
12
|
|
|
11
13
|
|
|
@@ -29,6 +31,13 @@ def test_skeleton(tmp_path):
|
|
|
29
31
|
assert doc.index('w:val="FirstParagraph"') < doc.index('w:val="BodyText"')
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
def test_dash_replacements(tmp_path):
|
|
35
|
+
out = tmp_path/'t.docx'
|
|
36
|
+
convert(to_mdhtml('scores 3--5 -- nice... `a--b`', callbacks={'text': replacements(*DASHES)}), out)
|
|
37
|
+
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
|
|
38
|
+
for s in ('3–5', ' – nice… ', 'a--b'): tt(s, doc, in_)
|
|
39
|
+
|
|
40
|
+
|
|
32
41
|
def test_html5_fragment_and_mutable_dom_input(tmp_path):
|
|
33
42
|
out = tmp_path/'t.docx'
|
|
34
43
|
dom = parse_mdhtml('<p>Before <div>middle</div> after.</p><span zoop:33="x">tail</span>'
|
|
@@ -165,14 +174,14 @@ def test_math(tmp_path):
|
|
|
165
174
|
tt('E = mc^2', doc, in_)
|
|
166
175
|
|
|
167
176
|
|
|
177
|
+
@pytest.mark.checkout
|
|
168
178
|
def test_sample(tmp_path):
|
|
169
179
|
"The loop-closer: mdhtml's packaged sample -> docx (smart, legally numbered); every emitted style resolves"
|
|
170
180
|
from lxml import etree
|
|
171
181
|
from mdhtml2docx.styles import STYLE_MAP, style_id
|
|
172
182
|
out = tmp_path/'sample.docx'
|
|
173
|
-
warns = convert(to_mdhtml(sample_md(),
|
|
174
|
-
teq(warns, [
|
|
175
|
-
'remote image not embedded: https://dummyimage.com/96x48/eeeeee/333333.png&text=fig'])
|
|
183
|
+
warns = convert(to_mdhtml(sample_md(), callbacks={'text': replacements(*DASHES)}, implicit_figures=True), out, base=SAMPLE_MD.parent, number_headings='legal')
|
|
184
|
+
teq(warns, [])
|
|
176
185
|
teq(fast_checks(out), 'valid')
|
|
177
186
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
178
187
|
z = zipfile.ZipFile(out)
|
|
@@ -188,7 +197,7 @@ def test_sample(tmp_path):
|
|
|
188
197
|
doc = z.read('word/document.xml').decode()
|
|
189
198
|
for s in (r'REF sec_late \w \h', r'REF sec_payment \w \h', r'PAGEREF sec_late \h',
|
|
190
199
|
r'REF fig_diagram \h', r'REF tbl_stages_n \h', ' SEQ Figure ', ' SEQ Table ',
|
|
191
|
-
'<w:br w:type="page"/>', 'Delivery stages'
|
|
200
|
+
'<w:br w:type="page"/>', 'Delivery stages'): tt(s, doc, in_)
|
|
192
201
|
tt('updateFields', z.read('word/settings.xml').decode(), in_)
|
|
193
202
|
md = pandoc(out)
|
|
194
203
|
for s in ('# mdhtml feature sample', '###### Week one', 'Temperature 1961-1990', '-89.2',
|
|
@@ -299,6 +308,21 @@ def test_xrefs(tmp_path):
|
|
|
299
308
|
tfail(lambda: convert('<p><a href="#sec-pay" data-ref="page text"></a></p>', out), contains='conflicting data-ref')
|
|
300
309
|
|
|
301
310
|
|
|
311
|
+
def test_text_targets(tmp_path):
|
|
312
|
+
out = tmp_path/'t.docx'
|
|
313
|
+
warns = convert(
|
|
314
|
+
'<h1 id="sec-d">Defs</h1>\n'
|
|
315
|
+
'<dl><dt id="def-term"><strong>"Term"</strong></dt><dt id="def-ap">Agreement Period</dt>'
|
|
316
|
+
'<dd>the deal period</dd></dl>\n'
|
|
317
|
+
'<p>The <span id="def-eff">Effective Date</span> is set.</p>\n'
|
|
318
|
+
'<p>The <a href="#def-term" data-ref=""></a> starts on the <a href="#def-eff" data-ref=""></a>; '
|
|
319
|
+
'see <a href="#def-ap" data-ref="page"></a>.</p>', out)
|
|
320
|
+
teq(warns, [])
|
|
321
|
+
teq(fast_checks(out), 'valid')
|
|
322
|
+
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
|
|
323
|
+
for s in (r'REF def_term \h', r'REF def_eff \h', r'PAGEREF def_ap \h',
|
|
324
|
+
'w:name="def_term"', 'w:name="def_eff"', '"Term"', 'Effective Date'): tt(s, doc, in_)
|
|
325
|
+
|
|
302
326
|
def test_xrefs_numbered_reference_doc(tmp_path):
|
|
303
327
|
"A numbered docx (made by us) as reference: numbering merges with list numbering, no reinjection"
|
|
304
328
|
ref = tmp_path/'ref.docx'
|
|
@@ -365,19 +389,15 @@ def test_caption_refs(tmp_path):
|
|
|
365
389
|
def test_template_tokens(tmp_path):
|
|
366
390
|
out = tmp_path/'t.docx'
|
|
367
391
|
src = to_mdhtml('Pay {{sal}} to {{name}}.\n\n{{#opt}}\n\nGranted.\n\n{{/opt}}\n', templates=MUSTACHE)
|
|
368
|
-
convert(src, out) # default:
|
|
392
|
+
convert(src, out) # default: vars dropped, markers literal
|
|
369
393
|
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
|
|
370
|
-
assert 'sal' not in doc and '{{' not in doc
|
|
394
|
+
assert 'sal' not in doc and '{{' not in doc and '«#opt»' in doc # vars dropped; markers stay visible
|
|
371
395
|
warns = convert(src, out, tmpl=mustache_fields)
|
|
372
396
|
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
|
|
373
397
|
assert 'MERGEFIELD sal' in doc and 'MERGEFIELD name' in doc
|
|
374
|
-
assert '
|
|
398
|
+
assert '«#opt»' in doc and '«/opt»' in doc # markers: converter-rendered literals, own paragraphs
|
|
375
399
|
teq(warns, [])
|
|
376
400
|
teq(fast_checks(out), 'valid')
|
|
377
|
-
convert(to_mdhtml('V {{ v }}.\n\n{% if x %}\n', templates=JINJA), out, tmpl=jinja_literal)
|
|
378
|
-
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
|
|
379
|
-
assert '{{ v }}' in doc and '{% if x %}' in doc
|
|
380
|
-
teq(fast_checks(out), 'valid')
|
|
381
401
|
|
|
382
402
|
def test_table_custom_style(tmp_path):
|
|
383
403
|
out = tmp_path/'t.docx'
|
|
@@ -391,9 +411,7 @@ def test_table_custom_style(tmp_path):
|
|
|
391
411
|
|
|
392
412
|
|
|
393
413
|
def test_template_controls(tmp_path):
|
|
394
|
-
def controls(
|
|
395
|
-
if mustache_kind(body) == 'section': return None
|
|
396
|
-
return 'control', body
|
|
414
|
+
def controls(node): return 'control', node['name']
|
|
397
415
|
out = tmp_path/'t.docx'
|
|
398
416
|
convert(to_mdhtml('Pay {{sal}} to {{name}}.\n\n{{#opt}}\n', templates=MUSTACHE), out, tmpl=controls)
|
|
399
417
|
doc = zipfile.ZipFile(out).read('word/document.xml').decode()
|
|
@@ -401,14 +419,12 @@ def test_template_controls(tmp_path):
|
|
|
401
419
|
assert doc.count('<w:showingPlcHdr/>') == 2 and 'w:val="PlaceholderText"' in doc
|
|
402
420
|
styles = zipfile.ZipFile(out).read('word/styles.xml').decode()
|
|
403
421
|
assert 'w:styleId="PlaceholderText"' in styles and 'w:val="808080"' in styles
|
|
404
|
-
assert '
|
|
422
|
+
assert '«#opt»' in doc # marker: converter-rendered literal
|
|
405
423
|
teq(fast_checks(out), 'valid')
|
|
406
424
|
|
|
407
425
|
|
|
408
426
|
def test_template_bound(tmp_path):
|
|
409
|
-
def bound(
|
|
410
|
-
if mustache_kind(body) == 'section': return None
|
|
411
|
-
return 'bound', body
|
|
427
|
+
def bound(node): return 'bound', node['name']
|
|
412
428
|
out = tmp_path/'t.docx'
|
|
413
429
|
convert(to_mdhtml('Pay {{sal}} to {{sal}} and {{name}}.\n', templates=MUSTACHE), out, tmpl=bound)
|
|
414
430
|
z = zipfile.ZipFile(out)
|
|
@@ -419,3 +435,13 @@ def test_template_bound(tmp_path):
|
|
|
419
435
|
assert 'customXmlProperties' in z.read('[Content_Types].xml').decode()
|
|
420
436
|
assert 'customXml' in z.read('word/_rels/document.xml.rels').decode()
|
|
421
437
|
teq(fast_checks(out), 'valid')
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_details_degrades_to_bold_label(tmp_path):
|
|
441
|
+
out = tmp_path/'t.docx'
|
|
442
|
+
warns = convert(to_mdhtml('::: {.details .tool-usage-details}\n## the label\n\nbody text\n:::\n'), out)
|
|
443
|
+
assert not warns
|
|
444
|
+
md = pandoc(out)
|
|
445
|
+
tt('**the label**', md, in_)
|
|
446
|
+
tt('body text', md, in_)
|
|
447
|
+
assert '# the label' not in md # label is a bold line, not a heading
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mdhtml import DASHES, replacements, to_mdhtml
|
|
5
|
+
from mdhtml.tools import SAMPLE_MD, sample_md
|
|
6
|
+
from mdhtml2docx import convert
|
|
7
|
+
|
|
8
|
+
DATA = Path(__file__).parent.parent/'_data'
|
|
9
|
+
|
|
10
|
+
pytestmark = [pytest.mark.slow, pytest.mark.checkout]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_sample():
|
|
14
|
+
docx = DATA/'sample.docx'
|
|
15
|
+
warns = convert(to_mdhtml(sample_md(), callbacks={'text': replacements(*DASHES)}, implicit_figures=True), docx, base=SAMPLE_MD.parent, number_headings='legal')
|
|
16
|
+
assert not warns
|
|
17
|
+
assert docx.stat().st_size
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
from mdhtml import sample_md, to_mdhtml
|
|
5
|
-
from mdhtml2docx import convert
|
|
6
|
-
|
|
7
|
-
DATA = Path(__file__).parent.parent/'_data'
|
|
8
|
-
|
|
9
|
-
pytestmark = pytest.mark.slow
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_sample():
|
|
13
|
-
docx = DATA/'sample.docx'
|
|
14
|
-
warns = convert(to_mdhtml(sample_md(), smart=True, auto_ids=True, implicit_figures=True), docx, number_headings='legal')
|
|
15
|
-
assert len(warns) == 2 and all('remote image not embedded' in w for w in warns)
|
|
16
|
-
assert docx.stat().st_size
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-customXmlSchemaProperties.xsd
RENAMED
|
File without changes
|
|
File without changes
|
{mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-relationshipReference.xsd
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|