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 ADDED
@@ -0,0 +1,3 @@
1
+ """Convert Persian/RTL Markdown files with Mermaid diagrams to PDF."""
2
+
3
+ __version__ = "0.3.0"
fa_md_pdf/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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