- fix Не появляется пустая странициа перед названием таблицы - fix Не появляется пустая страница перед альбомной ориентацией - fix При отказе от изменений создаётся новый файл - fix Замена "..." на «...» больше не затрагивает uml и mermaid блоки
This commit is contained in:
+1
-1
@@ -9,7 +9,6 @@ __pycache__/
|
|||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
build-fast/
|
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
downloads/
|
||||||
@@ -182,3 +181,4 @@ md2gost.schemes.json
|
|||||||
md2gost.include-cache.json
|
md2gost.include-cache.json
|
||||||
include-cache/
|
include-cache/
|
||||||
.md2gost-cache/
|
.md2gost-cache/
|
||||||
|
/build-fast
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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!
|
tracking down the missing module yourself. Thanks!
|
||||||
|
|
||||||
missing module named 'org.python' - imported by copy (optional), xml.sax (delayed, conditional)
|
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 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), 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 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.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 urllib.urlencode - imported by urllib (delayed, optional), lxml.html (delayed, optional)
|
||||||
missing module named pep517 - imported by importlib.metadata (delayed)
|
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 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)
|
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)
|
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
|
||||||
|
|||||||
@@ -27176,6 +27176,7 @@ imported by:
|
|||||||
• <a href="#md2gost.renderable.caption">md2gost.renderable.caption</a>
|
• <a href="#md2gost.renderable.caption">md2gost.renderable.caption</a>
|
||||||
• <a href="#md2gost.renderable.listing">md2gost.renderable.listing</a>
|
• <a href="#md2gost.renderable.listing">md2gost.renderable.listing</a>
|
||||||
• <a href="#md2gost.renderable.table">md2gost.renderable.table</a>
|
• <a href="#md2gost.renderable.table">md2gost.renderable.table</a>
|
||||||
|
• <a href="#md2gost.renderer">md2gost.renderer</a>
|
||||||
• <a href="#md2gost_exe.py">md2gost_exe.py</a>
|
• <a href="#md2gost_exe.py">md2gost_exe.py</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -27780,6 +27781,7 @@ imports:
|
|||||||
• <a href="#md2gost.layout_tracker">md2gost.layout_tracker</a>
|
• <a href="#md2gost.layout_tracker">md2gost.layout_tracker</a>
|
||||||
• <a href="#md2gost.numberer">md2gost.numberer</a>
|
• <a href="#md2gost.numberer">md2gost.numberer</a>
|
||||||
• <a href="#md2gost.page_geometry">md2gost.page_geometry</a>
|
• <a href="#md2gost.page_geometry">md2gost.page_geometry</a>
|
||||||
|
• <a href="#md2gost.profiles">md2gost.profiles</a>
|
||||||
• <a href="#md2gost.renderable">md2gost.renderable</a>
|
• <a href="#md2gost.renderable">md2gost.renderable</a>
|
||||||
• <a href="#md2gost.renderable.diagram">md2gost.renderable.diagram</a>
|
• <a href="#md2gost.renderable.diagram">md2gost.renderable.diagram</a>
|
||||||
• <a href="#md2gost.renderable.equation">md2gost.renderable.equation</a>
|
• <a href="#md2gost.renderable.equation">md2gost.renderable.equation</a>
|
||||||
|
|||||||
+15
-4
@@ -21,7 +21,7 @@ from .diagram_schemes import (
|
|||||||
)
|
)
|
||||||
from .dnd import enable_file_drop, first_markdown
|
from .dnd import enable_file_drop, first_markdown
|
||||||
from .help_content import SCHEMES_HELP, USAGE_HELP, load_prompt_catalog
|
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 (
|
from .profiles import (
|
||||||
DEFAULT_HEADING_NUMBERING,
|
DEFAULT_HEADING_NUMBERING,
|
||||||
DEFAULT_TABLE_CONTINUATION,
|
DEFAULT_TABLE_CONTINUATION,
|
||||||
@@ -1200,9 +1200,19 @@ class Md2GostApp:
|
|||||||
if not req.filename:
|
if not req.filename:
|
||||||
messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.")
|
messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.")
|
||||||
return
|
return
|
||||||
if not req.check_only and req.output and os.path.isfile(req.output):
|
if not req.check_only:
|
||||||
if not messagebox.askyesno("md2gost", f"Файл уже есть:\n{req.output}\n\nПерезаписать?"):
|
target = (req.output or "").strip() or (
|
||||||
return
|
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._busy = True
|
||||||
self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM)
|
self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM)
|
||||||
self.progress.start(12)
|
self.progress.start(12)
|
||||||
@@ -1225,6 +1235,7 @@ class Md2GostApp:
|
|||||||
self._append_log("Дебаг сброшен после этой сборки.")
|
self._append_log("Дебаг сброшен после этой сборки.")
|
||||||
if result.ok:
|
if result.ok:
|
||||||
if result.output_path:
|
if result.output_path:
|
||||||
|
self.output_var.set(result.output_path)
|
||||||
self._append_log("Готово.")
|
self._append_log("Готово.")
|
||||||
elif result.check_report:
|
elif result.check_report:
|
||||||
self._append_log("Проверка завершена.")
|
self._append_log("Проверка завершена.")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
|
|||||||
PlantUML / Kroki — Настройки → Диаграммы.
|
PlantUML / Kroki — Настройки → Диаграммы.
|
||||||
Свои UML-схемы — меню «Шаблоны UML».
|
Свои UML-схемы — меню «Шаблоны UML».
|
||||||
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
|
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
|
||||||
|
Если файл уже есть и вы откажетесь перезаписывать — сохранится как имя_гггг-мм-дд-ЧЧ-ММ.docx.
|
||||||
Дебаг (меню сверху) — следующая сборка с отладочными данными в документе.
|
Дебаг (меню сверху) — следующая сборка с отладочными данными в документе.
|
||||||
|
|
||||||
Типы: practice (по умолчанию) / coursework / vkr — ГОСТ МИРЭА; PIS_custom — отчёт по практикам ПИС; APID_coursework — курсовая АПИД.
|
Типы: practice (по умолчанию) / coursework / vkr — ГОСТ МИРЭА; PIS_custom — отчёт по практикам ПИС; APID_coursework — курсовая АПИД.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import platform
|
|||||||
import subprocess
|
import subprocess
|
||||||
import traceback
|
import traceback
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
from typing import Callable
|
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")
|
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:
|
def default_template_path() -> str:
|
||||||
return os.path.join(package_dir(), "Template.docx")
|
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.author = getuser()
|
||||||
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
|
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
|
||||||
|
try:
|
||||||
document.save(output)
|
document.save(output)
|
||||||
|
except PermissionError:
|
||||||
|
alt = timestamped_output_path(output)
|
||||||
|
emit(f"Не удалось записать {output} (файл занят). Сохраняю как {alt}")
|
||||||
|
document.save(alt)
|
||||||
|
output = alt
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
emit(traceback.format_exc())
|
emit(traceback.format_exc())
|
||||||
return ConvertResult(
|
return ConvertResult(
|
||||||
|
|||||||
+39
-5
@@ -99,6 +99,38 @@ def get_profile(doc_type: str) -> DocProfile:
|
|||||||
|
|
||||||
# --- text autofixes ---
|
# --- text autofixes ---
|
||||||
|
|
||||||
|
# ```uml / ```mermaid / ```uml-c4 / ```bpmn … — кавычки и тире там синтаксис
|
||||||
|
_FENCE_RE = re.compile(
|
||||||
|
r"^(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)\n"
|
||||||
|
r"(?P<body>[\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:
|
def fix_russian_quotes(text: str) -> str:
|
||||||
"""Replace "..." with «...» for Russian text segments (heuristic)."""
|
"""Replace "..." with «...» for Russian text segments (heuristic)."""
|
||||||
def repl(m):
|
def repl(m):
|
||||||
@@ -142,12 +174,14 @@ def dash_separator() -> str:
|
|||||||
def preprocess_markdown(text: str, emdash_to_hyphen: bool | None = None) -> str:
|
def preprocess_markdown(text: str, emdash_to_hyphen: bool | None = None) -> str:
|
||||||
if emdash_to_hyphen is not None:
|
if emdash_to_hyphen is not None:
|
||||||
set_emdash_to_hyphen(emdash_to_hyphen)
|
set_emdash_to_hyphen(emdash_to_hyphen)
|
||||||
text = fix_russian_quotes(text)
|
|
||||||
|
def _apply(chunk: str) -> str:
|
||||||
|
chunk = fix_russian_quotes(chunk)
|
||||||
if _EMDASH_TO_HYPHEN:
|
if _EMDASH_TO_HYPHEN:
|
||||||
# Keep ASCII hyphens; flatten any em dashes from the source
|
return replace_emdash_with_hyphen(chunk)
|
||||||
text = replace_emdash_with_hyphen(text)
|
return fix_dashes(chunk)
|
||||||
else:
|
|
||||||
text = fix_dashes(text)
|
text = _map_outside_diagram_fences(text, _apply)
|
||||||
text = separate_biblio_lines(text)
|
text = separate_biblio_lines(text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|||||||
@@ -67,14 +67,23 @@ class Caption(Renderable):
|
|||||||
layout_state.max_width
|
layout_state.max_width
|
||||||
).calculate_height()
|
).calculate_height()
|
||||||
|
|
||||||
if self._before and ((height_data.lines + 2 - 1) * height_data.line_spacing + 1) * height_data.line_height \
|
# Table/listing captions use keep_with_next. pageBreakBefore + keepWithNext
|
||||||
> layout_state.remaining_page_height:
|
# on a paragraph that Word already wraps to a new page produces a blank
|
||||||
self._docx_paragraph.paragraph_format.page_break_before = True
|
# 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(
|
height_data = ParagraphSizer(
|
||||||
self._docx_paragraph,
|
self._docx_paragraph,
|
||||||
None,
|
None,
|
||||||
layout_state.max_width
|
layout_state.max_width
|
||||||
).calculate_height()
|
).calculate_height()
|
||||||
|
|
||||||
yield RenderedInfo(self._docx_paragraph, height_data.full + (layout_state.remaining_page_height
|
self._docx_paragraph.paragraph_format.page_break_before = False
|
||||||
if self._docx_paragraph.paragraph_format.page_break_before else 0))
|
yield RenderedInfo(self._docx_paragraph, height_data.full + leftover)
|
||||||
|
|||||||
@@ -123,21 +123,13 @@ class Listing(Renderable, RequiresNumbering):
|
|||||||
def _emit_page_break_and_optional_caption(
|
def _emit_page_break_and_optional_caption(
|
||||||
self, layout_state: LayoutState
|
self, layout_state: LayoutState
|
||||||
) -> Generator[RenderedInfo, None, None]:
|
) -> 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))
|
page_break_info = next(PageBreak(self._parent).render(None, layout_state))
|
||||||
layout_state.add_height(page_break_info.height)
|
layout_state.add_height(page_break_info.height)
|
||||||
yield page_break_info
|
yield page_break_info
|
||||||
|
|
||||||
continuation_paragraph = self._make_continuation_paragraph()
|
continuation_paragraph = self._make_continuation_paragraph()
|
||||||
continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True
|
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)))
|
info = next(continuation_paragraph.render(None, copy(layout_state)))
|
||||||
layout_state.add_height(info.height)
|
layout_state.add_height(info.height)
|
||||||
yield info
|
yield info
|
||||||
|
|||||||
@@ -154,23 +154,15 @@ class Table(Renderable, RequiresNumbering):
|
|||||||
self, layout_state: LayoutState
|
self, layout_state: LayoutState
|
||||||
) -> Generator[RenderedInfo, None, None]:
|
) -> Generator[RenderedInfo, None, None]:
|
||||||
"""For legacy/caption only: break page and insert «Продолжение Таблицы N»."""
|
"""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))
|
page_break_info = next(PageBreak(self._parent).render(None, layout_state))
|
||||||
layout_state.add_height(page_break_info.height)
|
layout_state.add_height(page_break_info.height)
|
||||||
yield page_break_info
|
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 = self._make_continuation_paragraph()
|
||||||
continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True
|
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)))
|
info = next(continuation_paragraph.render(None, copy(layout_state)))
|
||||||
layout_state.add_height(info.height)
|
layout_state.add_height(info.height)
|
||||||
yield info
|
yield info
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from docx.enum.text import WD_TAB_LEADER, WD_TAB_ALIGNMENT, WD_PARAGRAPH_ALIGNME
|
|||||||
from docx.shared import Parented
|
from docx.shared import Parented
|
||||||
|
|
||||||
from . import Paragraph
|
from . import Paragraph
|
||||||
from .page_break import PageBreak
|
|
||||||
from .renderable import Renderable
|
from .renderable import Renderable
|
||||||
from ..layout_tracker import LayoutState
|
from ..layout_tracker import LayoutState
|
||||||
from ..rendered_info import RenderedInfo
|
from ..rendered_info import RenderedInfo
|
||||||
@@ -142,4 +141,5 @@ class ToC(Renderable):
|
|||||||
|
|
||||||
for rendered_info in self._paragraph.render(previous_rendered, copy(layout_state)):
|
for rendered_info in self._paragraph.render(previous_rendered, copy(layout_state)):
|
||||||
yield RenderedInfo(rendered_info.docx_element, 0)
|
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.
|
||||||
|
|||||||
+11
-5
@@ -95,12 +95,11 @@ class Renderer:
|
|||||||
self._landscape_depth += 1
|
self._landscape_depth += 1
|
||||||
return
|
return
|
||||||
self._flush_to_new_screen()
|
self._flush_to_new_screen()
|
||||||
if self._after_toc:
|
if not self._body_section_started:
|
||||||
self._ensure_body_section_with_page_numbers()
|
# Do not add_section(portrait) here. A portrait NEW_PAGE plus the
|
||||||
elif not self._body_section_started:
|
# landscape NEW_PAGE below is two breaks: text → empty portrait → album.
|
||||||
# No TOC — still need page numbers on subsequent sections.
|
|
||||||
self._body_section_started = True
|
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)
|
section = self._document.add_section(WD_SECTION.NEW_PAGE)
|
||||||
apply_section_geometry(section, landscape=True)
|
apply_section_geometry(section, landscape=True)
|
||||||
apply_centered_page_footer(section)
|
apply_centered_page_footer(section)
|
||||||
@@ -366,6 +365,13 @@ class Renderer:
|
|||||||
extra = self._after_current.pop(0)
|
extra = self._after_current.pop(0)
|
||||||
self.render(extra)
|
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:
|
if deferred_listing is not None:
|
||||||
self.render(deferred_listing)
|
self.render(deferred_listing)
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,86 @@ def test_landscape_places_figure_inside_landscape_section(tmp_path, monkeypatch)
|
|||||||
assert image_section == 1
|
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):
|
def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch):
|
||||||
"""+landscape +listing → listing must not sit in the landscape section."""
|
"""+landscape +listing → listing must not sit in the landscape section."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|||||||
@@ -190,6 +190,27 @@ def test_preprocess_quotes():
|
|||||||
assert "«тест»" in preprocess_markdown('Он сказал "тест" вслух.')
|
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():
|
def test_formula_refs():
|
||||||
assert find_formula_refs("см. @Формула:eq1 и далее") == {"eq1"}
|
assert find_formula_refs("см. @Формула:eq1 и далее") == {"eq1"}
|
||||||
|
|
||||||
@@ -466,6 +487,28 @@ def test_appendix_index_auto_inserted():
|
|||||||
assert out2 == [sec, manual, a, b]
|
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():
|
def test_caption_table_keep_with_next_and_toc_styles():
|
||||||
import docx
|
import docx
|
||||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
||||||
|
|||||||
+23
-1
@@ -4,7 +4,13 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from md2gost.dnd import first_markdown, normalize_drop_paths, parse_tkdnd_files
|
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
|
from md2gost.__main__ import build_parser, request_from_args
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +26,22 @@ def test_default_output_path():
|
|||||||
assert path.endswith("report.docx")
|
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():
|
def test_parse_tkdnd_files():
|
||||||
raw = r"{C:\My Files\a.md} C:\tmp\b.md"
|
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"]
|
assert parse_tkdnd_files(raw) == [r"C:\My Files\a.md", r"C:\tmp\b.md"]
|
||||||
|
|||||||
Reference in New Issue
Block a user