diff --git a/services/render/app/latex.py b/services/render/app/latex.py index ab16404..dd3dd0f 100644 --- a/services/render/app/latex.py +++ b/services/render/app/latex.py @@ -54,12 +54,14 @@ def esc(text: object) -> str: return out.replace(_BACKSLASH_MARK, r"\textbackslash{}") -# Markdown inline: pogrubienie, kursywa, kod, linki. Kolejność w alternatywie ma -# znaczenie — `**...**` musi być PRZED `*...*`, inaczej pogrubienie rozpadłoby się -# na dwie kursywy. Kursywę bierzemy tylko z gwiazdek (nie z podkreśleń): `_` pada -# w nazwach typu file_name, a jako kursywa dałoby fałszywe trafienia. +# Markdown inline: pogrubienie+kursywa, pogrubienie, kursywa, kod, linki. Kolejność +# w alternatywie ma znaczenie i jest OD NAJDŁUŻSZEGO znacznika: `***...***` przed +# `**...**` przed `*...*`. Inaczej `***mocne***` złapałoby się jako `**` + zgubiona +# gwiazdka i w PDF zostawałby wisior `*` (realny błąd, który to naprawia). Kursywę +# bierzemy tylko z gwiazdek (nie z podkreśleń): `_` pada w nazwach typu file_name. _INLINE = re.compile( - r"\*\*(?P.+?)\*\*" + r"\*\*\*(?P[^\n]+?)\*\*\*" + r"|\*\*(?P[^\n]+?)\*\*" r"|\*(?P[^*\n]+?)\*" r"|`(?P[^`\n]+?)`" r"|\[(?P[^\]]+)\]\((?P[^)]+)\)" @@ -73,7 +75,9 @@ def _inline(text: str) -> str: pos = 0 for m in _INLINE.finditer(text): out.append(esc(text[pos:m.start()])) - if m.group("bold") is not None: + if m.group("bolditalic") is not None: + out.append(r"\textbf{\textit{" + esc(m.group("bolditalic")) + "}}") + elif m.group("bold") is not None: out.append(r"\textbf{" + esc(m.group("bold")) + "}") elif m.group("ital") is not None: out.append(r"\textit{" + esc(m.group("ital")) + "}") @@ -120,15 +124,22 @@ def markdown_to_latex(text: str) -> str: for ln in lines: s = ln.strip() - heading = re.match(r"^(#{1,6})\s+(.*)$", s) + # Nagłówek celowo POBŁAŻLIWIE: dowolna liczba kratek na początku, spacja po + # nich NIEobowiązkowa, końcowe kratki (zamknięty ATX `## … ##`) ucinane. + # `\#` w LaTeXu renderuje się jako `#`, więc gdyby `##Bez spacji` trafiło do + # akapitu, w PDF WIDAĆ BY było `##`. Tu żaden znacznik nagłówka nie ostaje. + heading = re.match(r"^(#+)[ \t]*(.*?)[ \t]*#*$", s) bullet = re.match(r"^[-*+]\s+(.*)$", s) number = re.match(r"^\d+[.)]\s+(.*)$", s) if not s: flush_para(); flush_list() - elif heading: + elif heading: # linia zaczyna się od kratek flush_para(); flush_list() - cmd = _HEADING_CMD.get(len(heading.group(1)), "paragraph") - out.append(f"\\{cmd}*{{{_inline(heading.group(2))}}}") + content = heading.group(2) + if content: # gołe kratki (bez treści) po prostu pomijamy + level = min(len(heading.group(1)), 6) + cmd = _HEADING_CMD.get(level, "paragraph") + out.append(f"\\{cmd}*{{{_inline(content)}}}") elif bullet: flush_para() if env not in (None, "itemize"): diff --git a/services/render/tests/test_latex.py b/services/render/tests/test_latex.py index 1191716..5aee160 100644 --- a/services/render/tests/test_latex.py +++ b/services/render/tests/test_latex.py @@ -5,6 +5,8 @@ uruchomimy (brak TeX Live w tym środowisku), ale to i tak nie ona jest tu najbardziej krucha: błędna ucieczka nie wywala kompilacji, tylko po cichu psuje treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie. """ +import re + import pytest from app.latex import build, esc @@ -209,3 +211,62 @@ def test_bold_before_italic_not_two_italics(): def test_empty_and_none_are_safe(): assert md("") == "" assert md(None) == "" + + +# ───── regresja: znaczniki, które WIDAĆ w PDF mimo ucieczki (## i ***) ───── +# `\#` renderuje się jako `#`, a zgubiona gwiazdka zostaje `*`. Test na surowym +# `.tex` sprawdzający tylko podłańcuch „##" tego NIE łapie (w źródle jest `\#\#`), +# dlatego pilnujemy wprost: żadnego `\#` i żadnej wiszącej gwiazdki. + +def _no_visible_markers(out: str) -> bool: + """Czy w wyrenderowanym PDF NIE będzie widać znaczników markdown? + `\\#` wyszłoby jako `#`; jedyne dozwolone gwiazdki to część poleceń + `\\section*`/`\\subsection*`/`\\subsubsection*`/`\\paragraph*`.""" + if r"\#" in out: + return False + stripped = re.sub(r"\\(?:sub)*section\*|\\paragraph\*", "", out) + return "*" not in stripped + + +def test_triple_asterisk_becomes_bold_italic(): + """`***tekst***` = pogrubienie + kursywa, bez wiszących gwiazdek.""" + out = md("To ***bardzo mocne*** słowo.") + assert r"\textbf{\textit{bardzo mocne}}" in out + assert _no_visible_markers(out) + + +def test_triple_asterisk_in_list_and_midline(): + for src in ("- punkt z ***naciskiem***", "przed ***X*** po"): + assert _no_visible_markers(md(src)), src + + +def test_heading_without_space_is_converted_not_leaked(): + """`##Bez spacji` też ma zostać nagłówkiem — inaczej `\\#\\#` daje `##` w PDF.""" + out = md("##Bez spacji") + assert r"\subsubsection*{Bez spacji}" in out + assert _no_visible_markers(out) + + +def test_closed_atx_heading_drops_trailing_hashes(): + out = md("### Zamknięty ##") + assert r"\paragraph*{Zamknięty}" in out + assert _no_visible_markers(out) + + +def test_more_than_six_hashes_still_no_hash_leak(): + out = md("####### siedem kratek") + assert _no_visible_markers(out) + assert "siedem kratek" in out + + +def test_bare_hashes_line_is_dropped(): + assert md("###") == "" + + +def test_full_ai_markdown_leaves_no_visible_markers(): + """Realny miks od modelu: nagłówki (ze spacją i bez), potrójne gwiazdki, + zamknięty ATX — nic z tego nie ma być widać w PDF.""" + src = ("## Charakter\n\nOsoba ***wybitnie*** wrażliwa.\n\n" + "###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n" + "#### Zamknięty ####") + assert _no_visible_markers(md(src))