333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""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}"
|