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.
Files changed (40) hide show
  1. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/CHANGELOG.md +16 -1
  2. {mdhtml2docx-0.1.0/mdhtml2docx.egg-info → mdhtml2docx-0.1.2}/PKG-INFO +3 -3
  3. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/README.md +1 -1
  4. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/__init__.py +3 -1
  5. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/convert.py +58 -50
  6. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2/mdhtml2docx.egg-info}/PKG-INFO +3 -3
  7. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/requires.txt +1 -1
  8. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/pyproject.toml +4 -3
  9. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/tests/test_convert.py +47 -21
  10. mdhtml2docx-0.1.2/tests/test_word.py +17 -0
  11. mdhtml2docx-0.1.0/tests/test_word.py +0 -16
  12. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/LICENSE +0 -0
  13. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/MANIFEST.in +0 -0
  14. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/asvocab.py +0 -0
  15. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/hilite.py +0 -0
  16. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/SOURCES.md +0 -0
  17. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-chart.xsd +0 -0
  18. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-chartDrawing.xsd +0 -0
  19. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-diagram.xsd +0 -0
  20. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-lockedCanvas.xsd +0 -0
  21. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-main.xsd +0 -0
  22. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-picture.xsd +0 -0
  23. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/dml-wordprocessingDrawing.xsd +0 -0
  24. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-commonSimpleTypes.xsd +0 -0
  25. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-customXmlSchemaProperties.xsd +0 -0
  26. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-math.xsd +0 -0
  27. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/shared-relationshipReference.xsd +0 -0
  28. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/wml.xsd +0 -0
  29. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/schemas/xml.xsd +0 -0
  30. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/styles.py +0 -0
  31. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/templates/reference.docx +0 -0
  32. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/validate.py +0 -0
  33. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/wml.py +0 -0
  34. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/word.py +0 -0
  35. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx/word.sdef +0 -0
  36. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/SOURCES.txt +0 -0
  37. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/dependency_links.txt +0 -0
  38. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/mdhtml2docx.egg-info/top_level.txt +0 -0
  39. {mdhtml2docx-0.1.0 → mdhtml2docx-0.1.2}/setup.cfg +0 -0
  40. {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.0
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.15
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
 
@@ -1,3 +1,5 @@
1
- __version__ = "0.1.0"
1
+ __version__ = "0.1.2"
2
+
3
+
2
4
 
3
5
  from .convert import convert
@@ -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, group_plan, mustache_kind, ref_tokens, ref_variant
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', 'jinja_literal']
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 prefix word, or nothing for bare and caption refs"
199
- pre = el.to_text().strip()
200
- if not pre:
201
- tgt = (_get(el, 'href') or '#')[1:]
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), so `\\w` never applies to them."""
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
- if tgt not in self.reftarget:
215
- raise ValueError(f'cross-reference target #{tgt} not found (targets are headings, paragraphs, figures, and tables with ids)')
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 kind == 'page': instr, cached = rf' PAGEREF {nm} \h ', '#'
213
+ if variant == 'page': instr, cached = rf' PAGEREF {nm} \h ', '#'
219
214
  elif self.reftarget[tgt] == 'caption':
220
- bare = 'bare' in tokens or kind in ('leaf', 'rel')
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[kind]
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 kind == 'text' else '#'
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
- rows += _els(sec)
477
+ for c in _els(sec): _add(c)
478
478
  nhead = len(rows)
479
- elif t in ('tbody', 'tfoot'): rows += _els(sec)
480
- elif t == 'tr': rows.append(sec)
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 via the `tmpl` callable: str is a literal text run, ('field', instr) a live
598
- field, ('control', name) an interactive plain-text content control, None dropped"""
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(el.to_text(), _get(el, 'data-template', ''), form)
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'«{el.to_text().strip()}»')))]
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
- t = _tag(e)
845
- if t in ('figure', 'table'): self.reftarget[i] = 'caption'
846
- elif t in self.BOOKMARKABLE: self.reftarget[i] = 'block'
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(body, syntax, form):
892
- "Mustache variables as live Word `MERGEFIELD`s; section markers (`#`/`/`/`^` sigils) stay literal text"
893
- if mustache_kind(body) == 'section': return '{{' + body + '}}'
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 `(body, syntax, form) -> str` for a literal text run,
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` and
918
- `jinja_literal` are ready-made recipes."""
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.0
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.15
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
 
@@ -1,5 +1,5 @@
1
1
  lxml
2
- mdhtml>=0.1.15
2
+ mdhtml>=0.1.32
3
3
  fast5ever>=0.1.1
4
4
 
5
5
  [dev]
@@ -15,7 +15,7 @@ classifiers = [
15
15
  "Programming Language :: Python :: 3 :: Only",
16
16
  ]
17
17
 
18
- dependencies = ["lxml", "mdhtml>=0.1.15", "fast5ever>=0.1.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
- addopts = "-m 'not slow'"
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 JINJA, MUSTACHE, mustache_kind, parse_mdhtml, sample_md, to_mdhtml
8
- from mdhtml2docx.convert import convert, jinja_literal, mustache_fields
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(), smart=True, auto_ids=True, implicit_figures=True), out, number_headings='legal')
174
- teq(warns, ['remote image not embedded: https://dummyimage.com/96x48/eeeeee/333333.png&text=demo',
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', '“'): tt(s, doc, in_)
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: tokens dropped, as before
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 '{{#opt}}' in doc and '{{/opt}}' in doc # section markers stay literal, own paragraphs
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(body, syntax, form):
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 '{{' not in doc # section marker dropped by this callable
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(body, syntax, form):
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