mdpersia 0.3.0__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.
- fa_md_pdf/__init__.py +3 -0
- fa_md_pdf/__main__.py +4 -0
- fa_md_pdf/assets/default.css +319 -0
- fa_md_pdf/assets/mdpersia.ico +0 -0
- fa_md_pdf/assets/mdpersia.png +0 -0
- fa_md_pdf/assets/mdpersia.svg +7 -0
- fa_md_pdf/associations.py +249 -0
- fa_md_pdf/cli.py +871 -0
- fa_md_pdf/conversion_report.py +130 -0
- fa_md_pdf/converter.py +345 -0
- fa_md_pdf/defaults.py +117 -0
- fa_md_pdf/desktop_engine.py +95 -0
- fa_md_pdf/desktop_reader.py +50 -0
- fa_md_pdf/document_io.py +173 -0
- fa_md_pdf/docx_builder.py +1226 -0
- fa_md_pdf/html_builder.py +559 -0
- fa_md_pdf/input_files.py +32 -0
- fa_md_pdf/local_images.py +24 -0
- fa_md_pdf/math_renderer.py +69 -0
- fa_md_pdf/mermaid_renderer.py +167 -0
- fa_md_pdf/preview_worker.py +52 -0
- fa_md_pdf/print_layout.py +86 -0
- fa_md_pdf/rtl_wrapper.py +295 -0
- fa_md_pdf/word_document.py +142 -0
- fa_md_pdf/word_math.py +72 -0
- fa_md_pdf/word_numbering.py +51 -0
- fa_md_pdf/word_styles.py +289 -0
- mdpersia/__init__.py +37 -0
- mdpersia/__main__.py +4 -0
- mdpersia/assets/default.css +229 -0
- mdpersia-0.3.0.dist-info/METADATA +641 -0
- mdpersia-0.3.0.dist-info/RECORD +36 -0
- mdpersia-0.3.0.dist-info/WHEEL +5 -0
- mdpersia-0.3.0.dist-info/entry_points.txt +3 -0
- mdpersia-0.3.0.dist-info/licenses/LICENSE +21 -0
- mdpersia-0.3.0.dist-info/top_level.txt +2 -0
fa_md_pdf/__init__.py
ADDED
fa_md_pdf/__main__.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
html[dir="rtl"] {
|
|
6
|
+
direction: rtl;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
html[dir="ltr"] {
|
|
10
|
+
direction: ltr;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body {
|
|
14
|
+
text-align: start;
|
|
15
|
+
font-family: "Vazirmatn", "Noto Naskh Arabic", "Segoe UI", Tahoma, Arial, sans-serif;
|
|
16
|
+
font-size: 12pt;
|
|
17
|
+
line-height: 1.8;
|
|
18
|
+
color: #1f2937;
|
|
19
|
+
background: #ffffff;
|
|
20
|
+
margin: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.markdown-body {
|
|
24
|
+
max-width: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
p,
|
|
28
|
+
li,
|
|
29
|
+
blockquote,
|
|
30
|
+
td,
|
|
31
|
+
th,
|
|
32
|
+
figcaption {
|
|
33
|
+
unicode-bidi: isolate;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
html[dir="rtl"] body {
|
|
37
|
+
direction: rtl;
|
|
38
|
+
text-align: right;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
html[dir="ltr"] body {
|
|
42
|
+
direction: ltr;
|
|
43
|
+
text-align: left;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Direction-aware prose blocks:
|
|
47
|
+
Avoid unicode-bidi: plaintext on block elements, as plaintext classifies
|
|
48
|
+
any Persian sentence that opens with a Latin term/keyword as LTR!
|
|
49
|
+
*/
|
|
50
|
+
html[dir="rtl"] :is(p, li, dd, dt, blockquote, h1, h2, h3, h4, h5, h6, th, td, figcaption) {
|
|
51
|
+
direction: rtl;
|
|
52
|
+
text-align: right;
|
|
53
|
+
unicode-bidi: isolate;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
html[dir="ltr"] :is(p, li, dd, dt, blockquote, h1, h2, h3, h4, h5, h6, th, td, figcaption) {
|
|
57
|
+
direction: ltr;
|
|
58
|
+
text-align: left;
|
|
59
|
+
unicode-bidi: isolate;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Explicit direction overrides for bilingual mixed documents */
|
|
63
|
+
:is([dir="ltr"]:not(html, body), [dir="ltr"]:not(html, body) :is(p, li, dd, dt, blockquote, h1, h2, h3, h4, h5, h6, th, td)) {
|
|
64
|
+
direction: ltr !important;
|
|
65
|
+
text-align: left !important;
|
|
66
|
+
unicode-bidi: isolate;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
:is([dir="rtl"]:not(html, body), [dir="rtl"]:not(html, body) :is(p, li, dd, dt, blockquote, h1, h2, h3, h4, h5, h6, th, td)) {
|
|
70
|
+
direction: rtl !important;
|
|
71
|
+
text-align: right !important;
|
|
72
|
+
unicode-bidi: isolate;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Bi-directional phrasing elements */
|
|
76
|
+
:is(a, span, strong, em, b, i, u, s, mark, code) {
|
|
77
|
+
unicode-bidi: isolate;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
:is(h1, h2, h3, h4, h5, h6) {
|
|
81
|
+
color: #111827;
|
|
82
|
+
line-height: 1.45;
|
|
83
|
+
margin-top: 1.6em;
|
|
84
|
+
margin-bottom: 0.65em;
|
|
85
|
+
break-after: avoid;
|
|
86
|
+
unicode-bidi: isolate;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
h1 {
|
|
90
|
+
font-size: 24pt;
|
|
91
|
+
border-bottom: 1px solid #e5e7eb;
|
|
92
|
+
padding-bottom: 0.25em;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
h2 {
|
|
96
|
+
font-size: 18pt;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
h3 {
|
|
100
|
+
font-size: 15pt;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
p {
|
|
104
|
+
margin: 0.65em 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
a {
|
|
108
|
+
color: #075985;
|
|
109
|
+
text-decoration: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
blockquote {
|
|
113
|
+
border-inline-start: 4px solid #d1d5db;
|
|
114
|
+
border-inline-end: none;
|
|
115
|
+
margin: 1em 0;
|
|
116
|
+
padding-inline-start: 1em;
|
|
117
|
+
padding-inline-end: 0.5em;
|
|
118
|
+
padding-block: 0.3em;
|
|
119
|
+
color: #4b5563;
|
|
120
|
+
background: #f9fafb;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
ul,
|
|
124
|
+
ol {
|
|
125
|
+
padding-inline-start: 1.6em;
|
|
126
|
+
padding-inline-end: 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
ul[dir="rtl"],
|
|
130
|
+
ol[dir="rtl"] {
|
|
131
|
+
padding-inline-start: 1.6em !important;
|
|
132
|
+
padding-inline-end: 0 !important;
|
|
133
|
+
direction: rtl !important;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ul[dir="ltr"],
|
|
137
|
+
ol[dir="ltr"] {
|
|
138
|
+
padding-inline-start: 1.6em !important;
|
|
139
|
+
padding-inline-end: 0 !important;
|
|
140
|
+
direction: ltr !important;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
table {
|
|
144
|
+
width: 100%;
|
|
145
|
+
border-collapse: collapse;
|
|
146
|
+
margin: 1em 0;
|
|
147
|
+
break-inside: auto;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
thead {
|
|
151
|
+
display: table-header-group;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
tr {
|
|
155
|
+
break-inside: avoid;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
th,
|
|
159
|
+
td {
|
|
160
|
+
border: 1px solid #d1d5db;
|
|
161
|
+
padding: 0.45em 0.6em;
|
|
162
|
+
vertical-align: top;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
th {
|
|
166
|
+
background: #f3f4f6;
|
|
167
|
+
font-weight: 700;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
img,
|
|
171
|
+
svg {
|
|
172
|
+
max-width: 100%;
|
|
173
|
+
height: auto;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
pre {
|
|
177
|
+
direction: ltr !important;
|
|
178
|
+
text-align: left !important;
|
|
179
|
+
unicode-bidi: isolate !important;
|
|
180
|
+
font-family: Consolas, "Cascadia Mono", "Courier New", monospace;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
code,
|
|
184
|
+
kbd,
|
|
185
|
+
samp {
|
|
186
|
+
unicode-bidi: isolate !important;
|
|
187
|
+
font-family: Consolas, "Cascadia Mono", "Courier New", monospace;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
code {
|
|
191
|
+
background: #f3f4f6;
|
|
192
|
+
border-radius: 4px;
|
|
193
|
+
padding: 0.08em 0.25em;
|
|
194
|
+
font-size: 0.9em;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
pre {
|
|
198
|
+
background: #f3f4f6;
|
|
199
|
+
border: 1px solid #e5e7eb;
|
|
200
|
+
border-radius: 8px;
|
|
201
|
+
padding: 0.9em;
|
|
202
|
+
overflow-wrap: break-word;
|
|
203
|
+
white-space: pre-wrap;
|
|
204
|
+
margin: 1em 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
pre code {
|
|
208
|
+
background: transparent;
|
|
209
|
+
padding: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.mermaid {
|
|
213
|
+
direction: ltr;
|
|
214
|
+
text-align: center;
|
|
215
|
+
margin: 1.2em auto;
|
|
216
|
+
page-break-inside: avoid;
|
|
217
|
+
break-inside: avoid;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.mermaid svg {
|
|
221
|
+
max-width: 100%;
|
|
222
|
+
height: auto;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
hr {
|
|
226
|
+
border: none;
|
|
227
|
+
border-top: 1px solid #e5e7eb;
|
|
228
|
+
margin: 2em 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@media print {
|
|
232
|
+
body {
|
|
233
|
+
-webkit-print-color-adjust: exact;
|
|
234
|
+
print-color-adjust: exact;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
a {
|
|
238
|
+
color: inherit;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Page Break */
|
|
243
|
+
.page-break, hr.page-break {
|
|
244
|
+
page-break-after: always;
|
|
245
|
+
break-after: page;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* Callout / Admonitions */
|
|
249
|
+
blockquote.callout {
|
|
250
|
+
border-right-width: 4px;
|
|
251
|
+
border-radius: 6px;
|
|
252
|
+
padding: 0.8em 1.2em;
|
|
253
|
+
margin: 1.2em 0;
|
|
254
|
+
color: #1f2937;
|
|
255
|
+
}
|
|
256
|
+
.callout-title {
|
|
257
|
+
font-weight: 700;
|
|
258
|
+
margin-bottom: 0.4em;
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
gap: 0.5em;
|
|
262
|
+
}
|
|
263
|
+
blockquote.callout-note {
|
|
264
|
+
border-right-color: #2563eb;
|
|
265
|
+
background: #eff6ff;
|
|
266
|
+
}
|
|
267
|
+
blockquote.callout-tip {
|
|
268
|
+
border-right-color: #16a34a;
|
|
269
|
+
background: #f0fdf4;
|
|
270
|
+
}
|
|
271
|
+
blockquote.callout-important {
|
|
272
|
+
border-right-color: #7c3aed;
|
|
273
|
+
background: #f5f3ff;
|
|
274
|
+
}
|
|
275
|
+
blockquote.callout-warning {
|
|
276
|
+
border-right-color: #d97706;
|
|
277
|
+
background: #fffbeb;
|
|
278
|
+
}
|
|
279
|
+
blockquote.callout-caution {
|
|
280
|
+
border-right-color: #dc2626;
|
|
281
|
+
background: #fef2f2;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* Pygments Syntax Highlighting */
|
|
285
|
+
.math { direction: ltr; unicode-bidi: isolate; color: inherit; }
|
|
286
|
+
.math.block { overflow-x: auto; padding: 1rem .5rem; margin: 1rem 0;
|
|
287
|
+
text-align: center; break-inside: avoid; }
|
|
288
|
+
.math math { font-family: "Cambria Math", "STIX Two Math", math;
|
|
289
|
+
font-size: 1.15em; color: inherit; direction: ltr; }
|
|
290
|
+
.math math[display="block"] { margin: 0 auto; width: max-content; }
|
|
291
|
+
.math menclose[notation="box"] { border: 1px solid currentColor; padding: .15em .3em; }
|
|
292
|
+
.math-error { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
293
|
+
|
|
294
|
+
pre code .k, pre code .kd, pre code .kn, pre code .kr { color: #007020; font-weight: bold; } /* Keywords */
|
|
295
|
+
pre code .kt { color: #902000; font-weight: bold; } /* Type */
|
|
296
|
+
pre code .s, pre code .s1, pre code .s2, pre code .sb, pre code .sc, pre code .sd { color: #ba2121; } /* Strings */
|
|
297
|
+
pre code .c, pre code .c1, pre code .cm, pre code .cs, pre code .ch { color: #60a0b0; font-style: italic; } /* Comments */
|
|
298
|
+
pre code .m, pre code .mi, pre code .mf, pre code .mh, pre code .mo { color: #208050; } /* Numbers */
|
|
299
|
+
pre code .nf, pre code .fm { color: #06287e; font-weight: bold; } /* Functions */
|
|
300
|
+
pre code .nc, pre code .nn { color: #0e84b5; font-weight: bold; } /* Classes / Namespaces */
|
|
301
|
+
pre code .o, pre code .ow { color: #555555; } /* Operators */
|
|
302
|
+
pre code .nb, pre code .bp { color: #007020; } /* Builtins */
|
|
303
|
+
pre code .na, pre code .nv, pre code .vi, pre code .vg { color: #19177c; } /* Attributes / Variables */
|
|
304
|
+
|
|
305
|
+
@media screen {
|
|
306
|
+
.md-table-scroll { max-width: 100%; overflow-x: auto; }
|
|
307
|
+
}
|
|
308
|
+
.task-list-item { list-style-type: none; }
|
|
309
|
+
.task-list-item-checkbox { margin-inline: 0 .4em; }
|
|
310
|
+
.footnotes { margin-block-start: 2em; font-size: .92em; }
|
|
311
|
+
|
|
312
|
+
@media print {
|
|
313
|
+
.math.block { overflow: visible; }
|
|
314
|
+
.md-table-scroll { overflow: visible; }
|
|
315
|
+
.md-table-scroll table { width: 100%; table-layout: fixed; }
|
|
316
|
+
.md-table-scroll :is(th, td) { overflow-wrap: anywhere; }
|
|
317
|
+
.md-table-scroll pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
318
|
+
thead { display: table-header-group; }
|
|
319
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none">
|
|
2
|
+
<path d="M48 16h112l48 48v168a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V24a8 8 0 0 1 8-8Z" fill="#fff" stroke="#111" stroke-width="12" stroke-linejoin="round"/>
|
|
3
|
+
<path d="M156 18v52h50" stroke="#111" stroke-width="10" stroke-linejoin="round"/>
|
|
4
|
+
<path d="M66 150v-48l24 30 24-30v48M136 102h15c32 0 32 48 0 48h-15v-48Z" stroke="#111" stroke-width="12" stroke-linecap="square" stroke-linejoin="round"/>
|
|
5
|
+
<rect x="60" y="174" width="128" height="44" rx="8" fill="#111"/>
|
|
6
|
+
<path d="M161 196H88m15-11-15 11 15 11" stroke="#fff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Per-user Windows file association for the Markdown viewer.
|
|
2
|
+
|
|
3
|
+
HKCU only (no elevation), and unregister removes exactly the keys this module
|
|
4
|
+
creates, leaving the user's own default-program choice for `.md` untouched.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
PROG_ID = "MdPersia.Markdown"
|
|
15
|
+
PROGID_KEY = rf"Software\Classes\{PROG_ID}"
|
|
16
|
+
APP_NAME = "MdPersia Markdown Viewer"
|
|
17
|
+
VERB_LABEL = "باز کردن با MdPersia"
|
|
18
|
+
EXTENSIONS = (".md", ".markdown")
|
|
19
|
+
REGISTERED_APPS_KEY = r"Software\RegisteredApplications"
|
|
20
|
+
VIEWER_FLAG = "--gui"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RegEntry:
|
|
25
|
+
"""A single registry value: key path, value name, value data."""
|
|
26
|
+
|
|
27
|
+
key: str
|
|
28
|
+
name: str
|
|
29
|
+
value: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def location(self) -> str:
|
|
33
|
+
return f"HKCU\\{self.key}\\{self.name}" if self.name else f"HKCU\\{self.key}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def open_command(exe: str, extra_args: str | None = None) -> str:
|
|
37
|
+
"""Command line Explorer runs for a double-clicked file.
|
|
38
|
+
|
|
39
|
+
Double-clicking must open the viewer, never start a conversion: `mdpersia file.md`
|
|
40
|
+
without --gui is the CLI converter. The native Tauri exe also takes --gui because
|
|
41
|
+
`startup_arguments` keeps only arguments that name an existing file, so the flag is
|
|
42
|
+
inert there and cannot be mistaken for a document.
|
|
43
|
+
"""
|
|
44
|
+
if extra_args is None:
|
|
45
|
+
name = Path(exe).name.lower()
|
|
46
|
+
launcher = "-m mdpersia " if name.startswith("python") else ""
|
|
47
|
+
extra_args = f"{launcher}{VIEWER_FLAG}"
|
|
48
|
+
quoted = f'"{exe}"'
|
|
49
|
+
tail = f" {extra_args}" if extra_args else ""
|
|
50
|
+
return f'{quoted}{tail} "%1"'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_entries(exe_path: str | Path, icon_path: str | Path | None = None) -> list[RegEntry]:
|
|
54
|
+
exe = str(Path(exe_path))
|
|
55
|
+
# Use the application's Markdown mark even when Python launches the GUI.
|
|
56
|
+
bundled_icon = Path(__file__).parent / "assets" / "mdpersia.ico"
|
|
57
|
+
icon = str(Path(icon_path)) if icon_path else str(bundled_icon)
|
|
58
|
+
icon_value = f'"{icon}",0'
|
|
59
|
+
command = open_command(exe)
|
|
60
|
+
|
|
61
|
+
entries = [
|
|
62
|
+
RegEntry(PROGID_KEY, "", APP_NAME),
|
|
63
|
+
RegEntry(PROGID_KEY, "FriendlyTypeName", APP_NAME),
|
|
64
|
+
RegEntry(rf"{PROGID_KEY}\DefaultIcon", "", icon_value),
|
|
65
|
+
RegEntry(rf"{PROGID_KEY}\shell\open\command", "", command),
|
|
66
|
+
RegEntry(rf"{PROGID_KEY}\Application", "AppUserModelID", "MdPersia.Viewer"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Listed in "Open with" and in Settings > Default apps, without claiming .md.
|
|
70
|
+
for ext in EXTENSIONS:
|
|
71
|
+
entries.append(RegEntry(rf"{PROGID_KEY}\SupportedTypes", ext, ""))
|
|
72
|
+
|
|
73
|
+
entries.append(RegEntry(rf"{PROGID_KEY}\OpenWithList", "a", Path(exe).name))
|
|
74
|
+
entries.append(RegEntry(REGISTERED_APPS_KEY, "MdPersia", rf"{PROGID_KEY}\Capabilities"))
|
|
75
|
+
entries.append(RegEntry(rf"{PROGID_KEY}\Capabilities", "ApplicationName", APP_NAME))
|
|
76
|
+
for ext in EXTENSIONS:
|
|
77
|
+
entries.append(RegEntry(rf"{PROGID_KEY}\Capabilities\FileAssociations", ext, PROG_ID))
|
|
78
|
+
|
|
79
|
+
# Right-click verb on every .md file; does not change any default handler.
|
|
80
|
+
for ext in EXTENSIONS:
|
|
81
|
+
verb = rf"Software\Classes\SystemFileAssociations\{ext}\shell\MdPersia.view"
|
|
82
|
+
entries.append(RegEntry(verb, "", VERB_LABEL))
|
|
83
|
+
entries.append(RegEntry(verb, "Icon", icon_value))
|
|
84
|
+
entries.append(RegEntry(rf"{verb}\command", "", command))
|
|
85
|
+
|
|
86
|
+
return entries
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_uninstall_keys() -> list[str]:
|
|
90
|
+
"""Key trees to delete on unregister, deepest first."""
|
|
91
|
+
trees = [PROGID_KEY]
|
|
92
|
+
trees += [rf"Software\Classes\SystemFileAssociations\{ext}\shell\MdPersia.view" for ext in EXTENSIONS]
|
|
93
|
+
return trees
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _reg():
|
|
97
|
+
import winreg
|
|
98
|
+
|
|
99
|
+
return winreg
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def register(exe_path: str | Path, icon_path: str | Path | None = None) -> list[str]:
|
|
103
|
+
"""Write the per-user association. Returns the locations that were written."""
|
|
104
|
+
if sys.platform != "win32":
|
|
105
|
+
raise OSError("File associations are only available on Windows.")
|
|
106
|
+
winreg = _reg()
|
|
107
|
+
written: list[str] = []
|
|
108
|
+
for entry in build_entries(exe_path, icon_path):
|
|
109
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, entry.key) as key:
|
|
110
|
+
winreg.SetValueEx(key, entry.name, 0, winreg.REG_SZ, entry.value)
|
|
111
|
+
written.append(entry.location)
|
|
112
|
+
notify_shell()
|
|
113
|
+
return written
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def unregister() -> list[str]:
|
|
117
|
+
"""Remove only the keys this module creates. Missing keys are skipped."""
|
|
118
|
+
if sys.platform != "win32":
|
|
119
|
+
raise OSError("File associations are only available on Windows.")
|
|
120
|
+
winreg = _reg()
|
|
121
|
+
removed: list[str] = []
|
|
122
|
+
for tree in build_uninstall_keys():
|
|
123
|
+
try:
|
|
124
|
+
parent, _, leaf = tree.rpartition("\\")
|
|
125
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, parent, 0, winreg.KEY_ALL_ACCESS) as pk:
|
|
126
|
+
winreg.DeleteKey(pk, leaf)
|
|
127
|
+
removed.append(f"HKCU\\{tree}")
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
continue
|
|
130
|
+
# RegisteredApplications marker sits under a different parent.
|
|
131
|
+
try:
|
|
132
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTERED_APPS_KEY, 0, winreg.KEY_ALL_ACCESS) as pk:
|
|
133
|
+
winreg.DeleteValue(pk, "MdPersia")
|
|
134
|
+
removed.append(f"HKCU\\{REGISTERED_APPS_KEY}\\MdPersia")
|
|
135
|
+
except FileNotFoundError:
|
|
136
|
+
pass
|
|
137
|
+
notify_shell()
|
|
138
|
+
return removed
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_open_command() -> str | None:
|
|
142
|
+
"""The command Explorer currently runs, or None when nothing is registered."""
|
|
143
|
+
if sys.platform != "win32":
|
|
144
|
+
return None
|
|
145
|
+
winreg = _reg()
|
|
146
|
+
try:
|
|
147
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"{PROGID_KEY}\shell\open\command") as key:
|
|
148
|
+
return winreg.QueryValue(key, None) or None
|
|
149
|
+
except OSError:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def split_command(command: str) -> tuple[str, list[str]]:
|
|
154
|
+
"""Split a registry command line into (executable, args), dropping the quotes."""
|
|
155
|
+
import shlex
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
parts = [p.strip('"') for p in shlex.split(command, posix=False)]
|
|
159
|
+
except ValueError:
|
|
160
|
+
return "", []
|
|
161
|
+
return (parts[0], parts[1:]) if parts else ("", [])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _module_resolvable(exe: str, module: str) -> bool | None:
|
|
165
|
+
"""Ask the registered interpreter whether the module still exists. None = could not ask."""
|
|
166
|
+
import subprocess
|
|
167
|
+
|
|
168
|
+
probe = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec(sys.argv[1]) else 1)"
|
|
169
|
+
try:
|
|
170
|
+
done = subprocess.run(
|
|
171
|
+
[exe, "-c", probe, module], timeout=20, capture_output=True, check=False
|
|
172
|
+
)
|
|
173
|
+
except (OSError, subprocess.SubprocessError):
|
|
174
|
+
return None
|
|
175
|
+
return done.returncode == 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_open_command(
|
|
179
|
+
command: str | None,
|
|
180
|
+
*,
|
|
181
|
+
module_resolver=_module_resolvable,
|
|
182
|
+
exe_exists: Callable[[str], bool] = lambda p: Path(p).is_file(),
|
|
183
|
+
) -> tuple[bool, str]:
|
|
184
|
+
"""A stale entry outlives its target, so key presence alone is not 'working'."""
|
|
185
|
+
if not command:
|
|
186
|
+
return False, "not registered"
|
|
187
|
+
exe, args = split_command(command)
|
|
188
|
+
if not exe:
|
|
189
|
+
return False, "command is unreadable"
|
|
190
|
+
if not exe_exists(exe):
|
|
191
|
+
return False, f"executable is missing: {exe}"
|
|
192
|
+
if len(args) >= 2 and args[0] == "-m":
|
|
193
|
+
if module_resolver(exe, args[1]) is False:
|
|
194
|
+
return False, f"module '{args[1]}' is not importable by {exe}"
|
|
195
|
+
if VIEWER_FLAG not in args:
|
|
196
|
+
return False, f"command lacks {VIEWER_FLAG}, so a double-click runs the converter"
|
|
197
|
+
return True, exe
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def association_state() -> tuple[bool, str, str]:
|
|
201
|
+
"""(working, command, explanation) for the current per-user registration."""
|
|
202
|
+
command = read_open_command()
|
|
203
|
+
ok, reason = validate_open_command(command)
|
|
204
|
+
return ok, command or "", reason
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def is_registered() -> bool:
|
|
208
|
+
if sys.platform != "win32":
|
|
209
|
+
return False
|
|
210
|
+
winreg = _reg()
|
|
211
|
+
try:
|
|
212
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"{PROGID_KEY}\shell\open\command"):
|
|
213
|
+
return True
|
|
214
|
+
except OSError:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def notify_shell() -> None:
|
|
219
|
+
"""Tell Explorer the association database changed (best effort)."""
|
|
220
|
+
if sys.platform != "win32":
|
|
221
|
+
return
|
|
222
|
+
try:
|
|
223
|
+
import ctypes
|
|
224
|
+
|
|
225
|
+
SHCNE_ASSOCCHANGED = 0x08000000
|
|
226
|
+
SHCNF_IDLIST = 0x0000
|
|
227
|
+
ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def resolve_viewer_exe(project_root: Path, override: str | Path | None = None) -> Path:
|
|
233
|
+
"""Prefer an explicit path, then the built Tauri exe, then the module launcher."""
|
|
234
|
+
if override:
|
|
235
|
+
return Path(override)
|
|
236
|
+
release = (
|
|
237
|
+
project_root / "desktop-tauri" / "src-tauri" / "target" / "release" / "MdPersia.exe"
|
|
238
|
+
)
|
|
239
|
+
if release.is_file():
|
|
240
|
+
return release
|
|
241
|
+
return _windowless_launcher(Path(sys.executable))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _windowless_launcher(exe: Path) -> Path:
|
|
245
|
+
"""A double-click must not leave a console window open behind the viewer."""
|
|
246
|
+
if sys.platform != "win32" or exe.stem.lower() != "python":
|
|
247
|
+
return exe
|
|
248
|
+
windowless = exe.with_name("pythonw.exe")
|
|
249
|
+
return windowless if windowless.is_file() else exe
|