mddocx-native 1.2.1__py3-none-any.whl

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 (76) hide show
  1. mddocx/__init__.py +115 -0
  2. mddocx/accessibility.py +198 -0
  3. mddocx/api.py +352 -0
  4. mddocx/api_stability.py +46 -0
  5. mddocx/ast/__init__.py +5 -0
  6. mddocx/ast/base.py +23 -0
  7. mddocx/ast/block.py +166 -0
  8. mddocx/ast/codec.py +83 -0
  9. mddocx/ast/inline.py +82 -0
  10. mddocx/attributes.py +80 -0
  11. mddocx/batch.py +62 -0
  12. mddocx/benchmark.py +116 -0
  13. mddocx/bibliography.py +130 -0
  14. mddocx/cache.py +46 -0
  15. mddocx/cli.py +652 -0
  16. mddocx/config.py +362 -0
  17. mddocx/data.py +98 -0
  18. mddocx/diagnostics/__init__.py +4 -0
  19. mddocx/diagnostics/codes.py +32 -0
  20. mddocx/diagnostics/reporter.py +77 -0
  21. mddocx/diagrams/__init__.py +3 -0
  22. mddocx/diagrams/mermaid.py +543 -0
  23. mddocx/doctor.py +118 -0
  24. mddocx/extensions/__init__.py +4 -0
  25. mddocx/extensions/base.py +81 -0
  26. mddocx/extensions/discovery.py +38 -0
  27. mddocx/inspection.py +445 -0
  28. mddocx/interactive/__init__.py +4 -0
  29. mddocx/interactive/fonts.py +57 -0
  30. mddocx/interactive/history.py +98 -0
  31. mddocx/interactive/opening.py +17 -0
  32. mddocx/interactive/shell.py +921 -0
  33. mddocx/interactive/tokenize.py +14 -0
  34. mddocx/interactive/workspace.py +67 -0
  35. mddocx/limits.py +65 -0
  36. mddocx/math/__init__.py +3 -0
  37. mddocx/math/converter.py +47 -0
  38. mddocx/math/latex.py +383 -0
  39. mddocx/math/mathml.py +459 -0
  40. mddocx/metadata.py +524 -0
  41. mddocx/normalize/__init__.py +3 -0
  42. mddocx/normalize/normalizer.py +9 -0
  43. mddocx/ooxml/__init__.py +3 -0
  44. mddocx/ooxml/charts.py +489 -0
  45. mddocx/ooxml/comments.py +109 -0
  46. mddocx/ooxml/endnotes.py +191 -0
  47. mddocx/ooxml/fields.py +243 -0
  48. mddocx/ooxml/footnotes.py +191 -0
  49. mddocx/ooxml/numbering.py +154 -0
  50. mddocx/ooxml/tasks.py +71 -0
  51. mddocx/ooxml/text.py +81 -0
  52. mddocx/ooxml/utils.py +50 -0
  53. mddocx/parser/__init__.py +4 -0
  54. mddocx/parser/compatibility.py +446 -0
  55. mddocx/parser/frontmatter.py +70 -0
  56. mddocx/parser/markdown.py +519 -0
  57. mddocx/profiling.py +22 -0
  58. mddocx/project.py +739 -0
  59. mddocx/references.py +106 -0
  60. mddocx/render/__init__.py +3 -0
  61. mddocx/render/renderer.py +1171 -0
  62. mddocx/reproducibility.py +28 -0
  63. mddocx/resources/__init__.py +3 -0
  64. mddocx/resources/resolver.py +216 -0
  65. mddocx/styles/__init__.py +4 -0
  66. mddocx/styles/default.py +190 -0
  67. mddocx/styles/themes.py +49 -0
  68. mddocx/template_inspection.py +84 -0
  69. mddocx/validation.py +83 -0
  70. mddocx/visual_qa.py +178 -0
  71. mddocx_native-1.2.1.dist-info/METADATA +246 -0
  72. mddocx_native-1.2.1.dist-info/RECORD +76 -0
  73. mddocx_native-1.2.1.dist-info/WHEEL +5 -0
  74. mddocx_native-1.2.1.dist-info/entry_points.txt +2 -0
  75. mddocx_native-1.2.1.dist-info/licenses/LICENSE +21 -0
  76. mddocx_native-1.2.1.dist-info/top_level.txt +1 -0
mddocx/__init__.py ADDED
@@ -0,0 +1,115 @@
1
+ from .api import MarkdownWord, render, render_string
2
+ from .batch import BatchResult, collect_markdown_inputs, render_many
3
+ from .config import (
4
+ CacheConfig,
5
+ CompilationLimits,
6
+ FontConfig,
7
+ FooterConfig,
8
+ HeaderConfig,
9
+ ImageConfig,
10
+ Margins,
11
+ ListConfig,
12
+ MathFailurePolicy,
13
+ MermaidConfig,
14
+ ChartConfig,
15
+ DataConfig,
16
+ PageConfig,
17
+ PerformanceConfig,
18
+ PluginConfig,
19
+ RenderConfig,
20
+ ReproducibilityConfig,
21
+ ResourcePolicy,
22
+ TableConfig,
23
+ TOCConfig,
24
+ ValidationConfig,
25
+ ReferenceConfig, NotesConfig, CitationConfig, AccessibilityConfig, FigureConfig,
26
+ HeadingNumberingConfig, TitlePageConfig, AbstractConfig, CodeConfig, CalloutConfig, CommentConfig, FieldConfig, MetadataConfig,
27
+ )
28
+ from .extensions import MddocxExtension, load_entrypoint_extensions
29
+ from .profiling import RenderStats
30
+ from .validation import validate_docx_package
31
+ from .inspection import DocxInspection, inspect_docx, inspect_docx_bytes
32
+ from .visual_qa import VisualQAReport, compare_visual_pages, render_docx_pages
33
+ from .accessibility import AccessibilityFinding, AccessibilityReport, audit_docx_accessibility, audit_docx_accessibility_bytes
34
+ from .benchmark import BenchmarkReport, run_performance_gate
35
+ from .api_stability import PUBLIC_API_VERSION, get_public_api_manifest
36
+ from .metadata import MetadataSanitizationReport, SanitizedMarkdown, sanitize_markdown_metadata
37
+ from .project import (
38
+ ProjectManifest, ProjectCompilation, ProjectBuildResult, ProjectWatchEvent,
39
+ load_project, compile_project, build_project, watch_project, init_project, project_info,
40
+ )
41
+
42
+ __all__ = [
43
+ "MarkdownWord",
44
+ "RenderConfig",
45
+ "Margins",
46
+ "ListConfig",
47
+ "PageConfig",
48
+ "ResourcePolicy",
49
+ "MathFailurePolicy",
50
+ "MermaidConfig",
51
+ "ChartConfig",
52
+ "DataConfig",
53
+ "HeaderConfig",
54
+ "FooterConfig",
55
+ "TOCConfig",
56
+ "TableConfig",
57
+ "FontConfig",
58
+ "ImageConfig",
59
+ "PerformanceConfig",
60
+ "CompilationLimits",
61
+ "ReproducibilityConfig",
62
+ "ValidationConfig",
63
+ "CacheConfig",
64
+ "PluginConfig",
65
+ "ReferenceConfig",
66
+ "NotesConfig",
67
+ "CitationConfig",
68
+ "AccessibilityConfig",
69
+ "FigureConfig",
70
+ "HeadingNumberingConfig",
71
+ "TitlePageConfig",
72
+ "AbstractConfig",
73
+ "CodeConfig",
74
+ "CalloutConfig",
75
+ "CommentConfig",
76
+ "FieldConfig",
77
+ "MetadataConfig",
78
+ "MetadataSanitizationReport",
79
+ "SanitizedMarkdown",
80
+ "sanitize_markdown_metadata",
81
+ "RenderStats",
82
+ "MddocxExtension",
83
+ "load_entrypoint_extensions",
84
+ "BatchResult",
85
+ "collect_markdown_inputs",
86
+ "render_many",
87
+ "validate_docx_package",
88
+ "DocxInspection",
89
+ "inspect_docx",
90
+ "inspect_docx_bytes",
91
+ "VisualQAReport",
92
+ "compare_visual_pages",
93
+ "render_docx_pages",
94
+ "render",
95
+ "render_string",
96
+ "ProjectManifest",
97
+ "ProjectCompilation",
98
+ "ProjectBuildResult",
99
+ "ProjectWatchEvent",
100
+ "load_project",
101
+ "compile_project",
102
+ "build_project",
103
+ "watch_project",
104
+ "init_project",
105
+ "project_info",
106
+ "AccessibilityFinding",
107
+ "AccessibilityReport",
108
+ "audit_docx_accessibility",
109
+ "audit_docx_accessibility_bytes",
110
+ "BenchmarkReport",
111
+ "run_performance_gate",
112
+ "PUBLIC_API_VERSION",
113
+ "get_public_api_manifest",
114
+ ]
115
+ __version__ = "1.2.1"
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from io import BytesIO
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ from zipfile import BadZipFile, ZipFile
9
+
10
+ from lxml import etree
11
+
12
+ from .inspection import inspect_docx_bytes
13
+
14
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
15
+ M = "http://schemas.openxmlformats.org/officeDocument/2006/math"
16
+ WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
17
+ ADEC = "http://schemas.microsoft.com/office/drawing/2017/decorative"
18
+ DC = "http://purl.org/dc/elements/1.1/"
19
+ NS = {"w": W, "m": M, "wp": WP, "adec": ADEC, "dc": DC}
20
+
21
+ _SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3}
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class AccessibilityFinding:
26
+ code: str
27
+ severity: str
28
+ message: str
29
+ count: int = 1
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class AccessibilityReport:
34
+ findings: list[AccessibilityFinding] = field(default_factory=list)
35
+ images: int = 0
36
+ images_missing_alt: int = 0
37
+ semantic_tables: int = 0
38
+ tables_missing_header: int = 0
39
+ headings: int = 0
40
+ heading_level_jumps: int = 0
41
+ empty_hyperlinks: int = 0
42
+ document_title_present: bool = False
43
+
44
+ @property
45
+ def high_findings(self) -> int:
46
+ return sum(f.count for f in self.findings if f.severity == "high")
47
+
48
+ @property
49
+ def medium_findings(self) -> int:
50
+ return sum(f.count for f in self.findings if f.severity == "medium")
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ return self.high_findings == 0
55
+
56
+ def passes(self, fail_on: str = "high") -> bool:
57
+ threshold = _SEVERITY_RANK.get(fail_on, 3)
58
+ return not any(_SEVERITY_RANK.get(f.severity, 0) >= threshold for f in self.findings)
59
+
60
+ def to_dict(self) -> dict[str, object]:
61
+ return {
62
+ "ok": self.ok,
63
+ "images": self.images,
64
+ "images_missing_alt": self.images_missing_alt,
65
+ "semantic_tables": self.semantic_tables,
66
+ "tables_missing_header": self.tables_missing_header,
67
+ "headings": self.headings,
68
+ "heading_level_jumps": self.heading_level_jumps,
69
+ "empty_hyperlinks": self.empty_hyperlinks,
70
+ "document_title_present": self.document_title_present,
71
+ "findings": [asdict(f) for f in self.findings],
72
+ }
73
+
74
+ def to_json(self, indent: int | None = 2) -> str:
75
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False, sort_keys=True)
76
+
77
+ def to_text(self) -> str:
78
+ status = "PASS" if self.ok else "ISSUES"
79
+ lines = [
80
+ f"mddocx accessibility audit: {status}",
81
+ f"Images: {self.images} Missing alt text: {self.images_missing_alt}",
82
+ f"Semantic tables: {self.semantic_tables} Missing header rows: {self.tables_missing_header}",
83
+ f"Headings: {self.headings} Heading-level jumps: {self.heading_level_jumps}",
84
+ f"Empty hyperlinks: {self.empty_hyperlinks}",
85
+ f"Document title metadata: {'yes' if self.document_title_present else 'no'}",
86
+ ]
87
+ if self.findings:
88
+ lines.append("Findings:")
89
+ for finding in self.findings:
90
+ suffix = f" (x{finding.count})" if finding.count != 1 else ""
91
+ lines.append(f" {finding.severity.upper()} {finding.code}: {finding.message}{suffix}")
92
+ return "\n".join(lines)
93
+
94
+
95
+ def audit_docx_accessibility(path: str | Path) -> AccessibilityReport:
96
+ return audit_docx_accessibility_bytes(Path(path).read_bytes())
97
+
98
+
99
+ def audit_docx_accessibility_bytes(blob: bytes) -> AccessibilityReport:
100
+ report = AccessibilityReport()
101
+ package = inspect_docx_bytes(blob)
102
+ if not package.valid_zip:
103
+ report.findings.append(AccessibilityFinding("A11Y001", "high", "The input is not a valid DOCX package."))
104
+ return report
105
+ try:
106
+ with ZipFile(BytesIO(blob), "r") as zf:
107
+ parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False)
108
+ document = etree.fromstring(zf.read("word/document.xml"), parser=parser)
109
+ core = None
110
+ if "docProps/core.xml" in zf.namelist():
111
+ try:
112
+ core = etree.fromstring(zf.read("docProps/core.xml"), parser=parser)
113
+ except etree.XMLSyntaxError:
114
+ core = None
115
+ except (BadZipFile, KeyError, OSError, etree.XMLSyntaxError):
116
+ report.findings.append(AccessibilityFinding("A11Y001", "high", "The DOCX package cannot be audited safely."))
117
+ return report
118
+
119
+ docprs = document.xpath(".//wp:docPr", namespaces=NS)
120
+ report.images = len(docprs)
121
+ missing_alt = document.xpath(
122
+ ".//wp:docPr[(not(@descr) or normalize-space(@descr)='') and not(.//adec:decorative[@val='1'])]",
123
+ namespaces=NS,
124
+ )
125
+ report.images_missing_alt = len(missing_alt)
126
+ if report.images_missing_alt:
127
+ report.findings.append(AccessibilityFinding(
128
+ "A11Y101", "high", "Images or charts are missing alternative text.", report.images_missing_alt
129
+ ))
130
+
131
+ for table in document.xpath(".//w:tbl", namespaces=NS):
132
+ # Equation-number layout tables are presentation scaffolding, not semantic data tables.
133
+ if table.xpath(".//m:oMath", namespaces=NS) and len(table.xpath("./w:tr", namespaces=NS)) <= 1:
134
+ continue
135
+ report.semantic_tables += 1
136
+ first_rows = table.xpath("./w:tr[1]", namespaces=NS)
137
+ has_header = bool(first_rows and first_rows[0].xpath("./w:trPr/w:tblHeader", namespaces=NS))
138
+ if not has_header:
139
+ report.tables_missing_header += 1
140
+ if report.tables_missing_header:
141
+ report.findings.append(AccessibilityFinding(
142
+ "A11Y201", "medium", "Semantic tables should identify their first row as a repeating/header row.", report.tables_missing_header
143
+ ))
144
+
145
+ levels: list[int] = []
146
+ for p in document.xpath(".//w:p[w:pPr/w:pStyle]", namespaces=NS):
147
+ style_nodes = p.xpath("./w:pPr/w:pStyle/@w:val", namespaces=NS)
148
+ if not style_nodes:
149
+ continue
150
+ level = _heading_level(str(style_nodes[0]))
151
+ if level is not None:
152
+ levels.append(level)
153
+ report.headings = len(levels)
154
+ last = None
155
+ for level in levels:
156
+ if last is not None and level > last + 1:
157
+ report.heading_level_jumps += 1
158
+ last = level
159
+ if report.heading_level_jumps:
160
+ report.findings.append(AccessibilityFinding(
161
+ "A11Y301", "medium", "Heading hierarchy skips one or more levels.", report.heading_level_jumps
162
+ ))
163
+
164
+ empty_links = 0
165
+ for link in document.xpath(".//w:hyperlink", namespaces=NS):
166
+ text = "".join(link.itertext()).strip()
167
+ if not text:
168
+ empty_links += 1
169
+ report.empty_hyperlinks = empty_links
170
+ if empty_links:
171
+ report.findings.append(AccessibilityFinding(
172
+ "A11Y401", "high", "Hyperlinks with no readable link text were found.", empty_links
173
+ ))
174
+
175
+ title = ""
176
+ if core is not None:
177
+ nodes = core.xpath("./dc:title", namespaces=NS)
178
+ if nodes and nodes[0].text:
179
+ title = nodes[0].text.strip()
180
+ report.document_title_present = bool(title)
181
+ if not report.document_title_present:
182
+ report.findings.append(AccessibilityFinding(
183
+ "A11Y501", "low", "Document title metadata is empty. Set RenderConfig.title or YAML front matter title."
184
+ ))
185
+
186
+ if not levels:
187
+ report.findings.append(AccessibilityFinding(
188
+ "A11Y302", "low", "No semantic Word heading styles were detected."
189
+ ))
190
+ return report
191
+
192
+
193
+ def _heading_level(style: str) -> int | None:
194
+ compact = style.replace(" ", "")
195
+ match = re.search(r"(?:MD)?Heading([1-6])$", compact, re.IGNORECASE)
196
+ if match:
197
+ return int(match.group(1))
198
+ return None
mddocx/api.py ADDED
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from datetime import datetime
5
+ import hashlib
6
+ from pathlib import Path
7
+ from time import perf_counter
8
+ import tracemalloc
9
+ from typing import Any
10
+
11
+ from .ast.base import Document
12
+ from .cache import AstCache
13
+ from .config import RenderConfig
14
+ from .diagnostics import Diagnostic, DiagnosticReporter, MddocxError
15
+ from .extensions.base import call_transform_document
16
+ from .extensions.discovery import load_entrypoint_extensions
17
+ from .limits import enforce_ast_limits, enforce_input_limit
18
+ from .metadata import MetadataSanitizationReport, sanitize_markdown_metadata, scrub_generated_docx_core_properties
19
+ from .normalize import Normalizer
20
+ from .parser import MarkdownParser
21
+ from .profiling import RenderStats
22
+ from .render import DocxRenderer
23
+ from .reproducibility import make_reproducible_docx
24
+ from .validation import validate_docx_package
25
+
26
+
27
+ class MarkdownWord:
28
+ def __init__(self, config: RenderConfig | None = None):
29
+ self.config = deepcopy(config) if config is not None else RenderConfig()
30
+ self._config_was_provided = config is not None
31
+ if self.config.plugins.names:
32
+ discovered = load_entrypoint_extensions(
33
+ self.config.plugins.names, self.config.plugins.entrypoint_group
34
+ )
35
+ self.config.extensions = tuple(self.config.extensions) + discovered
36
+ self.reporter = DiagnosticReporter()
37
+ self.parser = MarkdownParser(self.config.extensions, self.config.metadata)
38
+ self.normalizer = Normalizer()
39
+ self.last_stats = RenderStats()
40
+
41
+ @property
42
+ def diagnostics(self):
43
+ return tuple(self.reporter.diagnostics)
44
+
45
+ def diagnostics_json(self, indent: int | None = 2) -> str:
46
+ return self.reporter.to_json(indent=indent)
47
+
48
+ def diagnostics_sarif(self, indent: int | None = 2) -> str:
49
+ return self.reporter.to_sarif(indent=indent)
50
+
51
+ def render_file(self, input_path: str | Path, output_path: str | Path) -> None:
52
+ input_path = Path(input_path)
53
+ output_path = Path(output_path)
54
+ try:
55
+ if input_path.stat().st_size > self.config.limits.max_input_bytes:
56
+ raise _input_limit_error(self.config.limits.max_input_bytes)
57
+ markdown = input_path.read_text(encoding="utf-8-sig")
58
+ blob = self._compile(markdown, input_path.parent, source_file=str(input_path))
59
+ output_path.parent.mkdir(parents=True, exist_ok=True)
60
+ output_path.write_bytes(blob)
61
+ except MddocxError as exc:
62
+ self._record_error(exc)
63
+ raise
64
+
65
+ def render_string(self, markdown: str, base_dir: str | Path | None = None) -> bytes:
66
+ return self._compile(
67
+ markdown,
68
+ Path(base_dir) if base_dir is not None else Path("."),
69
+ source_file=None,
70
+ )
71
+
72
+ def render_ast(self, document: Document, base_dir: str | Path | None = None) -> bytes:
73
+ self.reporter.diagnostics.clear()
74
+ base = Path(base_dir) if base_dir is not None else Path(".")
75
+ cfg = self._config_for_document(base, document.metadata)
76
+ started = perf_counter()
77
+ try:
78
+ transformed = call_transform_document(cfg.extensions, self.normalizer.normalize(document))
79
+ enforce_ast_limits(transformed, cfg.limits)
80
+ renderer = DocxRenderer(self.reporter)
81
+ blob = renderer.render(transformed, cfg)
82
+ blob = self._finalize_output(blob, cfg)
83
+ except MddocxError as exc:
84
+ self._record_error(exc)
85
+ raise
86
+ elapsed = (perf_counter() - started) * 1000
87
+ self.last_stats = RenderStats(
88
+ render_ms=elapsed,
89
+ total_ms=elapsed,
90
+ output_bytes=len(blob),
91
+ output_sha256=hashlib.sha256(blob).hexdigest(),
92
+ )
93
+ return blob
94
+
95
+ def check_file(self, input_path: str | Path) -> None:
96
+ self.reporter.diagnostics.clear()
97
+ input_path = Path(input_path)
98
+ try:
99
+ if input_path.stat().st_size > self.config.limits.max_input_bytes:
100
+ raise _input_limit_error(self.config.limits.max_input_bytes)
101
+ markdown = input_path.read_text(encoding="utf-8-sig")
102
+ enforce_input_limit(markdown, self.config.limits)
103
+ sanitized = sanitize_markdown_metadata(markdown, self.config.metadata)
104
+ self._report_metadata_sanitization(sanitized.report, str(input_path))
105
+ ast = self.normalizer.normalize(self.parser.parse(sanitized.markdown, source_file=str(input_path), sanitize_metadata=False))
106
+ cfg = self._config_for_document(input_path.parent, ast.metadata)
107
+ ast = call_transform_document(cfg.extensions, ast)
108
+ enforce_ast_limits(ast, cfg.limits)
109
+ except MddocxError as exc:
110
+ self._record_error(exc)
111
+ raise
112
+
113
+ def _compile(self, markdown: str, base_dir: Path, source_file: str | None) -> bytes:
114
+ self.reporter.diagnostics.clear()
115
+ cfg_for_memory = self.config.performance
116
+ started_tracemalloc = cfg_for_memory.track_memory and not tracemalloc.is_tracing()
117
+ if started_tracemalloc:
118
+ tracemalloc.start()
119
+ total_start = perf_counter()
120
+ cache_hit = False
121
+ try:
122
+ enforce_input_limit(markdown, self.config.limits)
123
+ sanitized = sanitize_markdown_metadata(markdown, self.config.metadata)
124
+ self._report_metadata_sanitization(sanitized.report, source_file)
125
+ markdown = sanitized.markdown
126
+ ast: Document | None = None
127
+ cache: AstCache | None = None
128
+ cache_key: str | None = None
129
+ if self.config.cache.ast_enabled:
130
+ if self.config.extensions:
131
+ self.reporter.info(
132
+ "CACHE402",
133
+ "Persistent AST cache is disabled when extensions are active.",
134
+ source_file,
135
+ )
136
+ else:
137
+ cache = AstCache(self.config.cache, base_dir)
138
+ cache_key = cache.key(markdown, source_file)
139
+ ast = cache.load(cache_key)
140
+ cache_hit = ast is not None
141
+ if cache_hit:
142
+ self.reporter.info("CACHE401", "Loaded normalized AST from persistent cache.", source_file)
143
+
144
+ parse_ms = 0.0
145
+ normalize_ms = 0.0
146
+ if ast is None:
147
+ parse_start = perf_counter()
148
+ ast = self.parser.parse(markdown, source_file=source_file, sanitize_metadata=False)
149
+ parse_ms = (perf_counter() - parse_start) * 1000
150
+
151
+ normalize_start = perf_counter()
152
+ ast = self.normalizer.normalize(ast)
153
+ normalize_ms = (perf_counter() - normalize_start) * 1000
154
+ if cache is not None and cache_key is not None:
155
+ try:
156
+ cache.save(cache_key, ast)
157
+ except (OSError, TypeError, ValueError) as exc:
158
+ self.reporter.warn("CACHE403", f"Unable to write persistent AST cache: {type(exc).__name__}", source_file)
159
+
160
+ cfg = self._config_for_document(base_dir, ast.metadata)
161
+ transform_start = perf_counter()
162
+ ast = call_transform_document(cfg.extensions, ast)
163
+ enforce_ast_limits(ast, cfg.limits)
164
+ normalize_ms += (perf_counter() - transform_start) * 1000
165
+
166
+ render_start = perf_counter()
167
+ renderer = DocxRenderer(self.reporter)
168
+ blob = renderer.render(ast, cfg)
169
+ blob = self._finalize_output(blob, cfg)
170
+ render_ms = (perf_counter() - render_start) * 1000
171
+ total_ms = (perf_counter() - total_start) * 1000
172
+
173
+ peak = None
174
+ if cfg_for_memory.track_memory and tracemalloc.is_tracing():
175
+ _, peak = tracemalloc.get_traced_memory()
176
+ self.last_stats = RenderStats(
177
+ parse_ms=parse_ms,
178
+ normalize_ms=normalize_ms,
179
+ render_ms=render_ms,
180
+ total_ms=total_ms,
181
+ peak_memory_bytes=peak,
182
+ output_bytes=len(blob),
183
+ output_sha256=hashlib.sha256(blob).hexdigest(),
184
+ ast_cache_hit=cache_hit,
185
+ )
186
+ return blob
187
+ except MddocxError as exc:
188
+ self._record_error(exc)
189
+ raise
190
+ finally:
191
+ if started_tracemalloc and tracemalloc.is_tracing():
192
+ tracemalloc.stop()
193
+
194
+ @staticmethod
195
+ def _finalize_output(blob: bytes, cfg: RenderConfig) -> bytes:
196
+ blob = scrub_generated_docx_core_properties(
197
+ blob, cfg.metadata, template_used=cfg.template is not None,
198
+ explicit={
199
+ "title": cfg.title is not None,
200
+ "author": cfg.author is not None,
201
+ "subject": cfg.subject is not None,
202
+ "keywords": cfg.keywords is not None,
203
+ "comments": cfg.comments is not None,
204
+ "created_at": cfg.created_at is not None,
205
+ "modified_at": False,
206
+ },
207
+ )
208
+ blob = make_reproducible_docx(blob, cfg.reproducibility)
209
+ validate_docx_package(blob, cfg.validation)
210
+ return blob
211
+
212
+ def _report_metadata_sanitization(self, report: MetadataSanitizationReport, source_file: str | None) -> None:
213
+ if report.changed:
214
+ parts = []
215
+ if report.removed_lines:
216
+ parts.append(f"{report.removed_lines} source line(s)")
217
+ if report.removed_front_matter_keys:
218
+ parts.append(f"{len(report.removed_front_matter_keys)} front-matter field(s)")
219
+ detail = " and ".join(parts) or "metadata"
220
+ self.reporter.info("META101", f"Removed AI/chat export metadata: {detail}.", source_file)
221
+ elif report.detected_export_metadata and report.policy == "keep":
222
+ self.reporter.info("META102", "AI/chat export metadata was detected and preserved by configuration.", source_file)
223
+
224
+ def _record_error(self, exc: MddocxError) -> None:
225
+ if not self.reporter.diagnostics or self.reporter.diagnostics[-1] != exc.diagnostic:
226
+ self.reporter.diagnostics.append(exc.diagnostic)
227
+
228
+ def _config_for_document(self, base_dir: Path, metadata: dict[str, Any]) -> RenderConfig:
229
+ cfg = deepcopy(self.config)
230
+ if cfg.base_dir is None:
231
+ cfg.base_dir = base_dir
232
+ return _apply_front_matter(cfg, metadata, self._config_was_provided)
233
+
234
+
235
+ def _input_limit_error(max_bytes: int) -> MddocxError:
236
+ return MddocxError(
237
+ Diagnostic("error", "LIMIT401", f"Markdown input exceeds {max_bytes} bytes.")
238
+ )
239
+
240
+
241
+ def _apply_front_matter(cfg: RenderConfig, metadata: dict[str, Any], explicit_config: bool) -> RenderConfig:
242
+ if not metadata:
243
+ return cfg
244
+ defaults = RenderConfig()
245
+
246
+ def can_use(current: Any, default: Any) -> bool:
247
+ return not explicit_config or current == default
248
+
249
+ scalar_fields = ("title", "author", "subject", "comments")
250
+ for name in scalar_fields:
251
+ if metadata.get(name) is not None and can_use(getattr(cfg, name), getattr(defaults, name)):
252
+ setattr(cfg, name, str(metadata[name]))
253
+ if metadata.get("keywords") is not None and can_use(cfg.keywords, defaults.keywords):
254
+ value = metadata["keywords"]
255
+ cfg.keywords = ", ".join(map(str, value)) if isinstance(value, list) else str(value)
256
+ if metadata.get("theme") is not None and can_use(cfg.theme, defaults.theme):
257
+ cfg.theme = str(metadata["theme"])
258
+ if metadata.get("page_size") in {"A4", "Letter"} and can_use(cfg.page.size, defaults.page.size):
259
+ cfg.page.size = metadata["page_size"]
260
+ if metadata.get("orientation") in {"portrait", "landscape"} and can_use(cfg.page.orientation, defaults.page.orientation):
261
+ cfg.page.orientation = metadata["orientation"]
262
+ if metadata.get("rtl") in {"off", "auto", "force"} and can_use(cfg.rtl, defaults.rtl):
263
+ cfg.rtl = metadata["rtl"]
264
+ if metadata.get("toc") is not None and can_use(cfg.toc.enabled, defaults.toc.enabled):
265
+ cfg.toc.enabled = bool(metadata["toc"])
266
+ if metadata.get("page_numbers") is not None and can_use(cfg.footer.page_number, defaults.footer.page_number):
267
+ cfg.footer.page_number = bool(metadata["page_numbers"])
268
+ cfg.footer.enabled = cfg.footer.enabled or cfg.footer.page_number
269
+ if metadata.get("auto_landscape_tables") is not None and can_use(cfg.table.auto_landscape, defaults.table.auto_landscape):
270
+ cfg.table.auto_landscape = bool(metadata["auto_landscape_tables"])
271
+ if metadata.get("header") is not None and can_use(cfg.header.text, defaults.header.text):
272
+ cfg.header.text = str(metadata["header"])
273
+ cfg.header.enabled = True
274
+ if metadata.get("footer") is not None and can_use(cfg.footer.text, defaults.footer.text):
275
+ cfg.footer.text = str(metadata["footer"])
276
+ cfg.footer.enabled = True
277
+ if metadata.get("notes") in {"footnote", "endnote"} and can_use(cfg.notes.style, defaults.notes.style):
278
+ cfg.notes.style = str(metadata["notes"])
279
+ if metadata.get("bibliography") is not None and can_use(cfg.citations.bibliography, defaults.citations.bibliography):
280
+ cfg.citations.bibliography = Path(str(metadata["bibliography"]))
281
+ if metadata.get("citation_style") in {"author-year", "apa", "ieee", "numeric"} and can_use(cfg.citations.style, defaults.citations.style):
282
+ cfg.citations.style = str(metadata["citation_style"])
283
+ if metadata.get("auto_bibliography") is not None and can_use(cfg.citations.auto_bibliography, defaults.citations.auto_bibliography):
284
+ cfg.citations.auto_bibliography = bool(metadata["auto_bibliography"])
285
+ if metadata.get("title_page") is not None and can_use(cfg.title_page.enabled, defaults.title_page.enabled):
286
+ cfg.title_page.enabled = bool(metadata["title_page"])
287
+ if metadata.get("subtitle") is not None and can_use(cfg.title_page.subtitle, defaults.title_page.subtitle):
288
+ cfg.title_page.subtitle = str(metadata["subtitle"])
289
+ cfg.title_page.enabled = True
290
+ if metadata.get("organization") is not None and can_use(cfg.title_page.organization, defaults.title_page.organization):
291
+ cfg.title_page.organization = str(metadata["organization"])
292
+ cfg.title_page.enabled = True
293
+ if metadata.get("title_date") is not None and can_use(cfg.title_page.date, defaults.title_page.date):
294
+ cfg.title_page.date = str(metadata["title_date"])
295
+ if metadata.get("abstract") is not None and can_use(cfg.abstract.text, defaults.abstract.text):
296
+ cfg.abstract.text = str(metadata["abstract"])
297
+ if metadata.get("abstract_title") is not None and can_use(cfg.abstract.title, defaults.abstract.title):
298
+ cfg.abstract.title = str(metadata["abstract_title"])
299
+ if metadata.get("keywords") is not None and not cfg.abstract.keywords:
300
+ value = metadata["keywords"]
301
+ cfg.abstract.keywords = tuple(map(str, value)) if isinstance(value, list) else tuple(x.strip() for x in str(value).split(",") if x.strip())
302
+ if metadata.get("heading_numbering") is not None and can_use(cfg.heading_numbering.enabled, defaults.heading_numbering.enabled):
303
+ cfg.heading_numbering.enabled = bool(metadata["heading_numbering"])
304
+ if metadata.get("heading_numbering_depth") is not None and can_use(cfg.heading_numbering.max_level, defaults.heading_numbering.max_level):
305
+ try:
306
+ cfg.heading_numbering.max_level = max(1, min(6, int(metadata["heading_numbering_depth"])))
307
+ except (TypeError, ValueError):
308
+ pass
309
+ if metadata.get("equation_numbering") in {"document", "section"} and can_use(cfg.references.equation_number_format, defaults.references.equation_number_format):
310
+ cfg.references.equation_number_format = str(metadata["equation_numbering"])
311
+ if metadata.get("caption_numbering") in {"document", "section"} and can_use(cfg.references.caption_number_format, defaults.references.caption_number_format):
312
+ cfg.references.caption_number_format = str(metadata["caption_numbering"])
313
+ if metadata.get("code_line_numbers") is not None and can_use(cfg.code.line_numbers, defaults.code.line_numbers):
314
+ cfg.code.line_numbers = bool(metadata["code_line_numbers"])
315
+ if metadata.get("syntax_highlighting") is not None and can_use(cfg.code.syntax_highlighting, defaults.code.syntax_highlighting):
316
+ cfg.code.syntax_highlighting = bool(metadata["syntax_highlighting"])
317
+ if metadata.get("page_x_of_y") is not None and can_use(cfg.footer.page_x_of_y, defaults.footer.page_x_of_y):
318
+ cfg.footer.page_x_of_y = bool(metadata["page_x_of_y"]); cfg.footer.enabled = cfg.footer.enabled or cfg.footer.page_x_of_y
319
+ if metadata.get("created_at") is not None and can_use(cfg.created_at, defaults.created_at):
320
+ try:
321
+ cfg.created_at = datetime.fromisoformat(str(metadata["created_at"]).replace("Z", "+00:00"))
322
+ except ValueError:
323
+ pass
324
+ return cfg
325
+
326
+
327
+ def _config_with_template(config: RenderConfig | None, template: str | Path | None) -> RenderConfig | None:
328
+ if template is None:
329
+ return config
330
+ cfg = deepcopy(config) if config is not None else RenderConfig()
331
+ cfg.template = Path(template)
332
+ return cfg
333
+
334
+
335
+ def render(
336
+ input_path: str | Path,
337
+ output_path: str | Path,
338
+ config: RenderConfig | None = None,
339
+ *,
340
+ template: str | Path | None = None,
341
+ ) -> None:
342
+ MarkdownWord(_config_with_template(config, template)).render_file(input_path, output_path)
343
+
344
+
345
+ def render_string(
346
+ markdown: str,
347
+ config: RenderConfig | None = None,
348
+ base_dir: str | Path | None = None,
349
+ *,
350
+ template: str | Path | None = None,
351
+ ) -> bytes:
352
+ return MarkdownWord(_config_with_template(config, template)).render_string(markdown, base_dir=base_dir)