diff --git a/.gitignore b/.gitignore index a7385b5..a58632c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ __pycache__/ # Distribution / packaging .Python build/ -build-fast/ develop-eggs/ dist/ downloads/ @@ -181,4 +180,5 @@ cython_debug/ md2gost.schemes.json md2gost.include-cache.json include-cache/ -.md2gost-cache/ \ No newline at end of file +.md2gost-cache/ +/build-fast diff --git a/build-fast/md2gost.fast/PYZ-00.pyz b/build-fast/md2gost.fast/PYZ-00.pyz index dc72f82..519ef84 100644 Binary files a/build-fast/md2gost.fast/PYZ-00.pyz and b/build-fast/md2gost.fast/PYZ-00.pyz differ diff --git a/build-fast/md2gost.fast/base_library.zip b/build-fast/md2gost.fast/base_library.zip index 66b48d6..9f0c08e 100644 Binary files a/build-fast/md2gost.fast/base_library.zip and b/build-fast/md2gost.fast/base_library.zip differ diff --git a/build-fast/md2gost.fast/md2gost.pkg b/build-fast/md2gost.fast/md2gost.pkg index efb989f..3c2c270 100644 Binary files a/build-fast/md2gost.fast/md2gost.pkg and b/build-fast/md2gost.fast/md2gost.pkg differ diff --git a/build-fast/md2gost.fast/warn-md2gost.fast.txt b/build-fast/md2gost.fast/warn-md2gost.fast.txt index 8b3e5f6..a508d88 100644 --- a/build-fast/md2gost.fast/warn-md2gost.fast.txt +++ b/build-fast/md2gost.fast/warn-md2gost.fast.txt @@ -15,12 +15,12 @@ IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for tracking down the missing module yourself. Thanks! missing module named 'org.python' - imported by copy (optional), xml.sax (delayed, conditional) -missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), backports.tarfile (optional), distutils.archive_util (optional), setuptools._distutils.archive_util (optional) -missing module named pwd - imported by posixpath (delayed, conditional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), getpass (delayed), distutils.util (delayed, conditional, optional), netrc (delayed, conditional), backports.tarfile (optional), distutils.archive_util (optional), http.server (delayed, optional), webbrowser (delayed), psutil (optional), setuptools._distutils.util (delayed, conditional, optional), setuptools._distutils.archive_util (optional) +missing module named grp - imported by subprocess (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), backports.tarfile (optional), distutils.archive_util (optional), setuptools._distutils.archive_util (optional) +missing module named pwd - imported by posixpath (delayed, conditional), subprocess (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), getpass (delayed), distutils.util (delayed, conditional, optional), netrc (delayed, conditional), backports.tarfile (optional), distutils.archive_util (optional), http.server (delayed, optional), webbrowser (delayed), psutil (optional), setuptools._distutils.util (delayed, conditional, optional), setuptools._distutils.archive_util (optional) missing module named urllib.urlopen - imported by urllib (delayed, optional), lxml.html (delayed, optional) missing module named urllib.urlencode - imported by urllib (delayed, optional), lxml.html (delayed, optional) missing module named pep517 - imported by importlib.metadata (delayed) -missing module named posix - imported by shutil (conditional), importlib._bootstrap_external (conditional), os (conditional, optional) +missing module named posix - imported by os (conditional, optional), shutil (conditional), importlib._bootstrap_external (conditional) missing module named resource - imported by posix (top-level) missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level) excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level) diff --git a/build-fast/md2gost.fast/xref-md2gost.fast.html b/build-fast/md2gost.fast/xref-md2gost.fast.html index 070803b..58caf95 100644 --- a/build-fast/md2gost.fast/xref-md2gost.fast.html +++ b/build-fast/md2gost.fast/xref-md2gost.fast.html @@ -27176,6 +27176,7 @@ imported by: • md2gost.renderable.captionmd2gost.renderable.listingmd2gost.renderable.table + • md2gost.renderermd2gost_exe.py @@ -27780,6 +27781,7 @@ imports: • md2gost.layout_trackermd2gost.numberermd2gost.page_geometry + • md2gost.profilesmd2gost.renderablemd2gost.renderable.diagrammd2gost.renderable.equation diff --git a/md2gost/gui.py b/md2gost/gui.py index 7504176..126e4dc 100644 --- a/md2gost/gui.py +++ b/md2gost/gui.py @@ -21,7 +21,7 @@ from .diagram_schemes import ( ) from .dnd import enable_file_drop, first_markdown from .help_content import SCHEMES_HELP, USAGE_HELP, load_prompt_catalog -from .pipeline import ConvertRequest, convert, default_output_path +from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path from .profiles import ( DEFAULT_HEADING_NUMBERING, DEFAULT_TABLE_CONTINUATION, @@ -1200,9 +1200,19 @@ class Md2GostApp: if not req.filename: messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.") return - if not req.check_only and req.output and os.path.isfile(req.output): - if not messagebox.askyesno("md2gost", f"Файл уже есть:\n{req.output}\n\nПерезаписать?"): - return + if not req.check_only: + target = (req.output or "").strip() or ( + default_output_path(req.filename) if req.filename else "" + ) + if target and os.path.isfile(target): + if not messagebox.askyesno( + "md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?" + ): + target = timestamped_output_path(target) + self._append_log(f"Не перезаписываем, пишем в {target}") + req.output = target + self.output_var.set(target) + self._auto_output = False self._busy = True self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM) self.progress.start(12) @@ -1225,6 +1235,7 @@ class Md2GostApp: self._append_log("Дебаг сброшен после этой сборки.") if result.ok: if result.output_path: + self.output_var.set(result.output_path) self._append_log("Готово.") elif result.check_report: self._append_log("Проверка завершена.") diff --git a/md2gost/help_content.py b/md2gost/help_content.py index 784219c..6606122 100644 --- a/md2gost/help_content.py +++ b/md2gost/help_content.py @@ -16,6 +16,7 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32) PlantUML / Kroki — Настройки → Диаграммы. Свои UML-схемы — меню «Шаблоны UML». 3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»). + Если файл уже есть и вы откажетесь перезаписывать — сохранится как имя_гггг-мм-дд-ЧЧ-ММ.docx. Дебаг (меню сверху) — следующая сборка с отладочными данными в документе. Типы: practice (по умолчанию) / coursework / vkr — ГОСТ МИРЭА; PIS_custom — отчёт по практикам ПИС; APID_coursework — курсовая АПИД. diff --git a/md2gost/pipeline.py b/md2gost/pipeline.py index d276609..fa1483d 100644 --- a/md2gost/pipeline.py +++ b/md2gost/pipeline.py @@ -8,6 +8,7 @@ import platform import subprocess import traceback from dataclasses import dataclass +from datetime import datetime from getpass import getuser from typing import Callable @@ -73,6 +74,25 @@ def default_output_path(filename: str) -> str: return os.path.join(os.path.dirname(os.path.abspath(filename)), base + ".docx") +def timestamped_output_path(path: str) -> str: + """report.docx → report_2026-09-05-10-05.docx (local time; seconds if taken).""" + directory, name = os.path.split(os.path.abspath(path)) + stem, ext = os.path.splitext(name) + if ext.lower() != ".docx": + ext = ".docx" + stamp = datetime.now().strftime("%Y-%m-%d-%H-%M") + candidate = os.path.join(directory, f"{stem}_{stamp}{ext}") + if not os.path.exists(candidate): + return candidate + stamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + candidate = os.path.join(directory, f"{stem}_{stamp}{ext}") + n = 2 + while os.path.exists(candidate): + candidate = os.path.join(directory, f"{stem}_{stamp}_{n}{ext}") + n += 1 + return candidate + + def default_template_path() -> str: return os.path.join(package_dir(), "Template.docx") @@ -235,7 +255,13 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult: document.core_properties.author = getuser() document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)" - document.save(output) + try: + document.save(output) + except PermissionError: + alt = timestamped_output_path(output) + emit(f"Не удалось записать {output} (файл занят). Сохраняю как {alt}") + document.save(alt) + output = alt except Exception as exc: emit(traceback.format_exc()) return ConvertResult( diff --git a/md2gost/profiles.py b/md2gost/profiles.py index ebdb16e..7c651c6 100644 --- a/md2gost/profiles.py +++ b/md2gost/profiles.py @@ -99,6 +99,38 @@ def get_profile(doc_type: str) -> DocProfile: # --- text autofixes --- +# ```uml / ```mermaid / ```uml-c4 / ```bpmn … — кавычки и тире там синтаксис +_FENCE_RE = re.compile( + r"^(?P`{3,}|~{3,})(?P[^\n]*)\n" + r"(?P[\s\S]*?)" + r"^(?P=fence)[ \t]*(?:\n|$)", + re.M, +) + + +def _fence_lang(info: str) -> str: + return (info.strip().split() or [""])[0].lower() + + +def _is_diagram_fence_info(info: str) -> bool: + from .diagram_schemes import is_diagram_lang + return is_diagram_lang(_fence_lang(info)) + + +def _map_outside_diagram_fences(text: str, fn) -> str: + """Apply fn only outside UML / Mermaid / scheme fences.""" + out: list[str] = [] + pos = 0 + for m in _FENCE_RE.finditer(text): + if not _is_diagram_fence_info(m.group("info")): + continue + out.append(fn(text[pos:m.start()])) + out.append(m.group(0)) + pos = m.end() + out.append(fn(text[pos:])) + return "".join(out) + + def fix_russian_quotes(text: str) -> str: """Replace "..." with «...» for Russian text segments (heuristic).""" def repl(m): @@ -142,12 +174,14 @@ def dash_separator() -> str: def preprocess_markdown(text: str, emdash_to_hyphen: bool | None = None) -> str: if emdash_to_hyphen is not None: set_emdash_to_hyphen(emdash_to_hyphen) - text = fix_russian_quotes(text) - if _EMDASH_TO_HYPHEN: - # Keep ASCII hyphens; flatten any em dashes from the source - text = replace_emdash_with_hyphen(text) - else: - text = fix_dashes(text) + + def _apply(chunk: str) -> str: + chunk = fix_russian_quotes(chunk) + if _EMDASH_TO_HYPHEN: + return replace_emdash_with_hyphen(chunk) + return fix_dashes(chunk) + + text = _map_outside_diagram_fences(text, _apply) text = separate_biblio_lines(text) return text diff --git a/md2gost/renderable/caption.py b/md2gost/renderable/caption.py index 3825caf..a3445cd 100644 --- a/md2gost/renderable/caption.py +++ b/md2gost/renderable/caption.py @@ -67,14 +67,23 @@ class Caption(Renderable): layout_state.max_width ).calculate_height() - if self._before and ((height_data.lines + 2 - 1) * height_data.line_spacing + 1) * height_data.line_height \ - > layout_state.remaining_page_height: - self._docx_paragraph.paragraph_format.page_break_before = True + # Table/listing captions use keep_with_next. pageBreakBefore + keepWithNext + # on a paragraph that Word already wraps to a new page produces a blank + # page that the layout tracker / debug overlay never sees. + leftover = 0 + need_new_page = ( + self._before + and layout_state.current_page_height > 0 + and ((height_data.lines + 2 - 1) * height_data.line_spacing + 1) * height_data.line_height + > layout_state.remaining_page_height + ) + if need_new_page: + leftover = layout_state.remaining_page_height height_data = ParagraphSizer( self._docx_paragraph, None, layout_state.max_width ).calculate_height() - yield RenderedInfo(self._docx_paragraph, height_data.full + (layout_state.remaining_page_height - if self._docx_paragraph.paragraph_format.page_break_before else 0)) + self._docx_paragraph.paragraph_format.page_break_before = False + yield RenderedInfo(self._docx_paragraph, height_data.full + leftover) diff --git a/md2gost/renderable/listing.py b/md2gost/renderable/listing.py index 8cc187c..1032392 100644 --- a/md2gost/renderable/listing.py +++ b/md2gost/renderable/listing.py @@ -123,21 +123,13 @@ class Listing(Renderable, RequiresNumbering): def _emit_page_break_and_optional_caption( self, layout_state: LayoutState ) -> Generator[RenderedInfo, None, None]: - mode = self._continuation_mode - if mode == "legacy": - continuation_paragraph = self._make_continuation_paragraph() - continuation_paragraph.page_break_before = True - info = next(continuation_paragraph.render(None, copy(layout_state))) - layout_state.add_height(info.height) - yield info - return - page_break_info = next(PageBreak(self._parent).render(None, layout_state)) layout_state.add_height(page_break_info.height) yield page_break_info continuation_paragraph = self._make_continuation_paragraph() continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True + continuation_paragraph._docx_paragraph.paragraph_format.page_break_before = False info = next(continuation_paragraph.render(None, copy(layout_state))) layout_state.add_height(info.height) yield info diff --git a/md2gost/renderable/table.py b/md2gost/renderable/table.py index 0b7d8ad..51c645d 100644 --- a/md2gost/renderable/table.py +++ b/md2gost/renderable/table.py @@ -154,23 +154,15 @@ class Table(Renderable, RequiresNumbering): self, layout_state: LayoutState ) -> Generator[RenderedInfo, None, None]: """For legacy/caption only: break page and insert «Продолжение Таблицы N».""" - mode = self._continuation_mode - - if mode == "legacy": - continuation_paragraph = self._make_continuation_paragraph() - continuation_paragraph.page_break_before = True - info = next(continuation_paragraph.render(None, copy(layout_state))) - layout_state.add_height(info.height) - yield info - return - - # caption page_break_info = next(PageBreak(self._parent).render(None, layout_state)) layout_state.add_height(page_break_info.height) yield page_break_info + # Never page_break_before on «Название таблицы»: keep_with_next + + # pageBreakBefore makes a blank page that debug does not count. continuation_paragraph = self._make_continuation_paragraph() continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True + continuation_paragraph._docx_paragraph.paragraph_format.page_break_before = False info = next(continuation_paragraph.render(None, copy(layout_state))) layout_state.add_height(info.height) yield info diff --git a/md2gost/renderable/toc.py b/md2gost/renderable/toc.py index e481804..0481cdc 100644 --- a/md2gost/renderable/toc.py +++ b/md2gost/renderable/toc.py @@ -7,7 +7,6 @@ from docx.enum.text import WD_TAB_LEADER, WD_TAB_ALIGNMENT, WD_PARAGRAPH_ALIGNME from docx.shared import Parented from . import Paragraph -from .page_break import PageBreak from .renderable import Renderable from ..layout_tracker import LayoutState from ..rendered_info import RenderedInfo @@ -142,4 +141,5 @@ class ToC(Renderable): for rendered_info in self._paragraph.render(previous_rendered, copy(layout_state)): yield RenderedInfo(rendered_info.docx_element, 0) - yield from PageBreak(self._parent).render(None, copy(layout_state)) + # Page break after TOC is a section break in Renderer (body + PAGE). + # An extra w:br here + NEW_PAGE section = blank portrait page. diff --git a/md2gost/renderer.py b/md2gost/renderer.py index 06df6ca..a47c570 100644 --- a/md2gost/renderer.py +++ b/md2gost/renderer.py @@ -95,12 +95,11 @@ class Renderer: self._landscape_depth += 1 return self._flush_to_new_screen() - if self._after_toc: - self._ensure_body_section_with_page_numbers() - elif not self._body_section_started: - # No TOC — still need page numbers on subsequent sections. + if not self._body_section_started: + # Do not add_section(portrait) here. A portrait NEW_PAGE plus the + # landscape NEW_PAGE below is two breaks: text → empty portrait → album. self._body_section_started = True - apply_centered_page_footer(self._document.sections[0]) + apply_centered_page_footer(self._document.sections[-1]) section = self._document.add_section(WD_SECTION.NEW_PAGE) apply_section_geometry(section, landscape=True) apply_centered_page_footer(section) @@ -366,6 +365,13 @@ class Renderer: extra = self._after_current.pop(0) self.render(extra) + if isinstance(renderable, ToC): + # One NEXT_PAGE section after the TOC field — not a w:br plus a section. + self._ensure_body_section_with_page_numbers() + state = self._layout_tracker.current_state + if state.current_page_height > 0: + self._layout_tracker.new_page() + if deferred_listing is not None: self.render(deferred_listing) diff --git a/tests/test_landscape.py b/tests/test_landscape.py index 4354631..9f4137e 100644 --- a/tests/test_landscape.py +++ b/tests/test_landscape.py @@ -156,6 +156,86 @@ def test_landscape_places_figure_inside_landscape_section(tmp_path, monkeypatch) assert image_section == 1 +W_NS = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + + +def _is_break_paragraph(el) -> bool: + """Empty paragraph that is a page break and/or hosts a section break.""" + if el.tag != f"{W_NS}p": + return False + texts = [t.text or "" for t in el.findall(f".//{W_NS}t")] + if any(t.strip() for t in texts): + return False + pPr = el.find(f"{W_NS}pPr") + has_sect = pPr is not None and pPr.find(f"{W_NS}sectPr") is not None + has_br = any(br.get(f"{W_NS}type") == "page" for br in el.findall(f".//{W_NS}br")) + return has_sect or has_br + + +def _breaks_between_last_text_and_table(doc) -> int: + """Empty page/section-break paras between the last body sentence and the table.""" + children = list(doc.element.body) + table_i = next(i for i, el in enumerate(children) if el.tag == f"{W_NS}tbl") + j = table_i - 1 + breaks = 0 + while j >= 0: + el = children[j] + if _is_break_paragraph(el): + breaks += 1 + j -= 1 + continue + if el.tag == f"{W_NS}p": + text = "".join(t.text or "" for t in el.findall(f".//{W_NS}t")).strip() + if text.startswith("Таблица") or text.startswith("Продолжение"): + j -= 1 + continue + break + return breaks + + +def test_portrait_to_landscape_is_single_section_break(): + """Text then +landscape: one next-page section, no extra blank portrait page.""" + doc = Document(os.path.join(package_dir(), "Template.docx")) + body = doc._body._element + for child in list(body): + if child.tag.endswith("}sectPr"): + continue + body.remove(child) + + from md2gost.renderable.heading import Heading + from md2gost.renderable.paragraph import Paragraph + from md2gost.renderable.toc import ToC + + h = Heading(doc._body, 1, False) + h.add_run("СОДЕРЖАНИЕ") + toc = ToC(doc._body, toc_mode="native") + para = Paragraph(doc._body) + para.add_run("Текст перед широкой таблицей.") + table = Table(doc._body, 2, 3, CaptionInfo("wide", "Карта", landscape=True)) + for r in range(2): + for c in range(3): + table.add_paragraph_to_cell(r, c).add_run(f"{r}{c}") + + Renderer(doc, skip_numbering=True).process([h, toc, para, table]) + assert _breaks_between_last_text_and_table(doc) == 1 + + # Same without TOC — still a single break, not page-break + section-break. + doc2 = Document(os.path.join(package_dir(), "Template.docx")) + body2 = doc2._body._element + for child in list(body2): + if child.tag.endswith("}sectPr"): + continue + body2.remove(child) + para2 = Paragraph(doc2._body) + para2.add_run("Текст перед широкой таблицей.") + table2 = Table(doc2._body, 2, 3, CaptionInfo("wide2", "Карта", landscape=True)) + for r in range(2): + for c in range(3): + table2.add_paragraph_to_cell(r, c).add_run(f"{r}{c}") + Renderer(doc2, skip_numbering=True).process([para2, table2]) + assert _breaks_between_last_text_and_table(doc2) == 1 + + def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch): """+landscape +listing → listing must not sit in the landscape section.""" from unittest.mock import patch diff --git a/tests/test_mirea_tz.py b/tests/test_mirea_tz.py index 185d9bf..9666395 100644 --- a/tests/test_mirea_tz.py +++ b/tests/test_mirea_tz.py @@ -190,6 +190,27 @@ def test_preprocess_quotes(): assert "«тест»" in preprocess_markdown('Он сказал "тест" вслух.') +def test_preprocess_skips_uml_and_mermaid_fences(): + md = ( + 'В тексте "кавычки".\n\n' + "```uml\n" + 'rectangle "Акционеры" as own\n' + "```\n\n" + "```mermaid\n" + 'title "Выручка"\n' + "```\n\n" + "```uml-c4context\n" + 'Person(user, "Заявитель")\n' + "```\n" + ) + out = preprocess_markdown(md) + assert "«кавычки»" in out + assert 'rectangle "Акционеры" as own' in out + assert 'title "Выручка"' in out + assert 'Person(user, "Заявитель")' in out + assert "«Акционеры»" not in out + + def test_formula_refs(): assert find_formula_refs("см. @Формула:eq1 и далее") == {"eq1"} @@ -466,6 +487,28 @@ def test_appendix_index_auto_inserted(): assert out2 == [sec, manual, a, b] +def test_table_caption_does_not_set_page_break_before(): + import os + import docx + from docx.shared import Mm + from md2gost import package_dir + from md2gost.styles import apply_mirea_styles + from md2gost.layout_tracker import LayoutState + from md2gost.renderable.caption import Caption, CaptionInfo + + doc = docx.Document(os.path.join(package_dir(), "Template.docx")) + apply_mirea_styles(doc) + cap = Caption(doc._body, "Таблица", CaptionInfo("t1", "Реквизиты"), "1", True) + state = LayoutState(Mm(250), Mm(160)) + state.add_height(Mm(248)) + leftover = state.remaining_page_height + infos = list(cap.render(None, state)) + assert len(infos) == 1 + assert cap._docx_paragraph.style.name == "Название таблицы" + assert cap._docx_paragraph.paragraph_format.page_break_before in (None, False) + assert infos[0].height > leftover + + def test_caption_table_keep_with_next_and_toc_styles(): import docx from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 57236e9..304b204 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -4,7 +4,13 @@ import os import sys from md2gost.dnd import first_markdown, normalize_drop_paths, parse_tkdnd_files -from md2gost.pipeline import ConvertRequest, convert, default_output_path, should_launch_gui +from md2gost.pipeline import ( + ConvertRequest, + convert, + default_output_path, + should_launch_gui, + timestamped_output_path, +) from md2gost.__main__ import build_parser, request_from_args @@ -20,6 +26,22 @@ def test_default_output_path(): assert path.endswith("report.docx") +def test_timestamped_output_path(tmp_path): + import re + src = tmp_path / "ПР1.docx" + src.write_bytes(b"x") + out = timestamped_output_path(str(src)) + name = os.path.basename(out) + assert re.fullmatch(r"ПР1_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.docx", name) + assert not os.path.exists(out) + # collision → seconds (or seconds_2) + open(out, "wb").close() + out2 = timestamped_output_path(str(src)) + assert out2 != out + assert os.path.basename(out2).startswith("ПР1_") + assert out2.endswith(".docx") + + def test_parse_tkdnd_files(): raw = r"{C:\My Files\a.md} C:\tmp\b.md" assert parse_tkdnd_files(raw) == [r"C:\My Files\a.md", r"C:\tmp\b.md"]