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.
- mddocx/__init__.py +115 -0
- mddocx/accessibility.py +198 -0
- mddocx/api.py +352 -0
- mddocx/api_stability.py +46 -0
- mddocx/ast/__init__.py +5 -0
- mddocx/ast/base.py +23 -0
- mddocx/ast/block.py +166 -0
- mddocx/ast/codec.py +83 -0
- mddocx/ast/inline.py +82 -0
- mddocx/attributes.py +80 -0
- mddocx/batch.py +62 -0
- mddocx/benchmark.py +116 -0
- mddocx/bibliography.py +130 -0
- mddocx/cache.py +46 -0
- mddocx/cli.py +652 -0
- mddocx/config.py +362 -0
- mddocx/data.py +98 -0
- mddocx/diagnostics/__init__.py +4 -0
- mddocx/diagnostics/codes.py +32 -0
- mddocx/diagnostics/reporter.py +77 -0
- mddocx/diagrams/__init__.py +3 -0
- mddocx/diagrams/mermaid.py +543 -0
- mddocx/doctor.py +118 -0
- mddocx/extensions/__init__.py +4 -0
- mddocx/extensions/base.py +81 -0
- mddocx/extensions/discovery.py +38 -0
- mddocx/inspection.py +445 -0
- mddocx/interactive/__init__.py +4 -0
- mddocx/interactive/fonts.py +57 -0
- mddocx/interactive/history.py +98 -0
- mddocx/interactive/opening.py +17 -0
- mddocx/interactive/shell.py +921 -0
- mddocx/interactive/tokenize.py +14 -0
- mddocx/interactive/workspace.py +67 -0
- mddocx/limits.py +65 -0
- mddocx/math/__init__.py +3 -0
- mddocx/math/converter.py +47 -0
- mddocx/math/latex.py +383 -0
- mddocx/math/mathml.py +459 -0
- mddocx/metadata.py +524 -0
- mddocx/normalize/__init__.py +3 -0
- mddocx/normalize/normalizer.py +9 -0
- mddocx/ooxml/__init__.py +3 -0
- mddocx/ooxml/charts.py +489 -0
- mddocx/ooxml/comments.py +109 -0
- mddocx/ooxml/endnotes.py +191 -0
- mddocx/ooxml/fields.py +243 -0
- mddocx/ooxml/footnotes.py +191 -0
- mddocx/ooxml/numbering.py +154 -0
- mddocx/ooxml/tasks.py +71 -0
- mddocx/ooxml/text.py +81 -0
- mddocx/ooxml/utils.py +50 -0
- mddocx/parser/__init__.py +4 -0
- mddocx/parser/compatibility.py +446 -0
- mddocx/parser/frontmatter.py +70 -0
- mddocx/parser/markdown.py +519 -0
- mddocx/profiling.py +22 -0
- mddocx/project.py +739 -0
- mddocx/references.py +106 -0
- mddocx/render/__init__.py +3 -0
- mddocx/render/renderer.py +1171 -0
- mddocx/reproducibility.py +28 -0
- mddocx/resources/__init__.py +3 -0
- mddocx/resources/resolver.py +216 -0
- mddocx/styles/__init__.py +4 -0
- mddocx/styles/default.py +190 -0
- mddocx/styles/themes.py +49 -0
- mddocx/template_inspection.py +84 -0
- mddocx/validation.py +83 -0
- mddocx/visual_qa.py +178 -0
- mddocx_native-1.2.1.dist-info/METADATA +246 -0
- mddocx_native-1.2.1.dist-info/RECORD +76 -0
- mddocx_native-1.2.1.dist-info/WHEEL +5 -0
- mddocx_native-1.2.1.dist-info/entry_points.txt +2 -0
- mddocx_native-1.2.1.dist-info/licenses/LICENSE +21 -0
- 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"
|
mddocx/accessibility.py
ADDED
|
@@ -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)
|