"""Tests for landscape section around wide figures/tables.""" from __future__ import annotations import os from pathlib import Path from docx import Document from docx.enum.section import WD_ORIENT from PIL import Image as PILImage from md2gost import package_dir from md2gost.page_geometry import content_size from md2gost.parser_ import Parser from md2gost.renderable.caption import CaptionInfo from md2gost.renderable.image import Image from md2gost.renderable.table import Table from md2gost.renderer import Renderer from docx.oxml.ns import qn def _section_page_start(section): pg = section._sectPr.find(qn("w:pgNumType")) if pg is None: return None return pg.get(qn("w:start")) def test_content_size_landscape_wider(): h_p, w_p = content_size(landscape=False) h_l, w_l = content_size(landscape=True) assert w_l > w_p assert h_l < h_p def test_renderer_landscape_section_for_table(): 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) renderer = Renderer(doc, skip_numbering=False) table = Table(doc._body, 2, 4, CaptionInfo("t1", "Wide", landscape=True)) for r in range(2): for c in range(4): table.add_paragraph_to_cell(r, c).add_run(f"{r},{c}") before = len(doc.sections) renderer.process([table]) after = len(doc.sections) assert after >= before + 2 land = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE] assert land, "no landscape section" assert int(land[0].page_width) > int(land[0].page_height) from md2gost.styles import apply_document_styles apply_document_styles(doc, "mirea") land2 = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE] assert land2 assert int(land2[0].page_width) > int(land2[0].page_height) assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT assert int(doc.sections[-1].page_width) < int(doc.sections[-1].page_height) # Landscape and return-to-portrait must not restart PAGE (no w:start). for section in land2: assert _section_page_start(section) is None assert _section_page_start(doc.sections[-1]) is None def test_landscape_sections_have_no_page_restart(): """+landscape must not leave w:pgNumType/@w:start on album / following portrait.""" 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}") after = Paragraph(doc._body) after.add_run("Текст после альбома.") Renderer(doc, skip_numbering=True).process([h, toc, para, table, after]) land = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE] assert land for section in land: assert _section_page_start(section) is None, "landscape restarted PAGE" # Body portrait after landscape also continuous. assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT assert _section_page_start(doc.sections[-1]) is None # Non-first sections must not restart (first may still carry template start). for section in list(doc.sections)[1:]: assert _section_page_start(section) is None def test_parser_propagates_landscape_to_table(): doc = Document(os.path.join(package_dir(), "Template.docx")) md = "%t1 Широкая +landscape\n\n|A|B|C|\n|---|---|---|\n|1|2|3|\n" renderables = list(Parser(doc, md).parse()) assert len(renderables) == 1 assert isinstance(renderables[0], Table) assert renderables[0].landscape is True def test_landscape_image_not_queued_for_soft_pagebreak(tmp_path, monkeypatch): """Portrait fit-check must not queue +landscape images (empty page before figure).""" monkeypatch.chdir(tmp_path) png = tmp_path / "wide.png" # Wide image that is tall when forced into portrait width PILImage.new("RGB", (2000, 1400), color=(0, 128, 255)).save(png) os.environ["WORKING_DIR"] = str(tmp_path) 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.paragraph import Paragraph as RParagraph from md2gost.sub_renderable import SubRenderable para = RParagraph(doc._body) para.add_run("Before text that fills some space. " * 20) img = Image( doc._body, str(png), CaptionInfo("deploy", "Диаграмма", landscape=True), ) # Mimic factory: image attached to paragraph para._images.append(img) from md2gost.layout_tracker import LayoutState from md2gost.page_geometry import content_size from docx.shared import Length max_h, max_w = content_size(landscape=False) state = LayoutState(max_h, max_w) # Pretend most of the page is already used so a portrait fit-check would fail state.add_height(Length(int(max_h) - 100000)) infos = list(para.render(None, state)) subs = [i for i in infos if isinstance(i, SubRenderable)] assert len(subs) == 1 assert subs[0].renderable is img assert subs[0].add_to_new_page is False def test_landscape_places_figure_inside_landscape_section(tmp_path, monkeypatch): """Content must sit BEFORE body sectPr or Word ignores landscape.""" monkeypatch.chdir(tmp_path) png = tmp_path / "x.png" PILImage.new("RGB", (40, 20), color=(0, 128, 255)).save(png) 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) os.environ["WORKING_DIR"] = str(tmp_path) img = Image( doc._body, str(png), CaptionInfo("fig", "wide", landscape=True), ) renderer = Renderer(doc, skip_numbering=False) renderer.process([img]) A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" section_idx = 0 image_section = None for child in doc.element.body: tag = child.tag.split("}")[-1] if child.findall(f".//{A}blip"): image_section = section_idx sect = None if tag == "sectPr": sect = child elif tag == "p": pPr = child.find(f"{W}pPr") if pPr is not None: sect = pPr.find(f"{W}sectPr") if sect is not None: section_idx += 1 assert any(s.orientation == WD_ORIENT.LANDSCAPE for s in doc.sections) assert image_section is not None 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 from md2gost.renderable.caption import CaptionInfo from md2gost.renderable.diagram import DiagramFigure from md2gost.renderer import Renderer from md2gost.styles import apply_mirea_styles monkeypatch.chdir(tmp_path) png = tmp_path / "d.png" PILImage.new("RGB", (40, 20), color=(0, 128, 255)).save(png) 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) apply_mirea_styles(doc) class FakeResult: png_path = str(png) svg_path = None pixel_scale = 1.0 fig = DiagramFigure( doc._body, "uml", "@startuml\nA->B\n@enduml", CaptionInfo("d1", "Схема", with_listing=True, landscape=True), with_listing=True, ) assert fig.listing is not None renderer = Renderer(doc, skip_numbering=False) with patch("md2gost.renderable.diagram.render_diagram", return_value=FakeResult()): renderer.process([fig]) assert any(s.orientation == WD_ORIENT.LANDSCAPE for s in doc.sections) texts = [p.text for p in doc.paragraphs] listing_caps = [t for t in texts if t.startswith("Листинг")] assert listing_caps, f"no listing caption in {texts!r}"