- fix Не появляется пустая странициа перед названием таблицы - fix Не появляется пустая страница перед альбомной ориентацией - fix При отказе от изменений создаётся новый файл - fix Замена "..." на «...» больше не затрагивает uml и mermaid блоки
This commit is contained in:
+15
-4
@@ -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("Проверка завершена.")
|
||||
|
||||
@@ -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 — курсовая АПИД.
|
||||
|
||||
+27
-1
@@ -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(
|
||||
|
||||
+40
-6
@@ -99,6 +99,38 @@ def get_profile(doc_type: str) -> DocProfile:
|
||||
|
||||
# --- 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:
|
||||
"""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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+11
-5
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user