fix(render): potrójne gwiazdki i kratki bez spacji nie wyciekają do PDF
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m59s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build-render / build (push) Successful in 8m52s
build / build (push) Successful in 7m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m40s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m3s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 28s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m59s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 39s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build-render / build (push) Successful in 8m52s
build / build (push) Successful in 7m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 13m40s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m3s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 28s
Dwie realne dziury w markdown→LaTeX, obie WIDOCZNE w gotowym PDF:
1. `***mocne***` (pogrubienie+kursywa) łapało się jako `**` + zgubiona gwiazdka →
`\textbf{*mocne}*`, czyli w druku zostawał wisior `*`. Dokładam alternatywę
`\*\*\*…\*\*\*` PRZED `**` i `*` (kolejność od najdłuższego znacznika) →
`\textbf{\textit{…}}`.
2. Nagłówek bez spacji po kratkach (`##Tytuł`), zamknięty ATX (`## Tytuł ##`)
i 7+ kratek trafiały do akapitu i były eskejpowane jako `\#\#…`. Pułapka: w
LaTeXu `\#` renderuje się jako `#`, więc w PDF WIDAĆ było `##`, choć w źródle
`.tex` jest `\#\#`. Dlatego stary test (`"##" not in out`) tego nie łapał —
podłańcuch `##` nie występuje w `\#\#`. Nagłówki traktujemy teraz pobłażliwie:
dowolna liczba kratek na starcie, spacja nieobowiązkowa, końcowe kratki
ucinane, gołe kratki bez treści pomijane. Żaden znacznik nagłówka nie ostaje.
Nowa asercja w testach patrzy pod kątem RENDERU: brak `\#` i brak wiszącej
gwiazdki (poza gwiazdką poleceń `\section*` itd.). +7 testów regresji, render 34.
UWAGA DEPLOY: obraz render w rejestrze to wciąż 56131b20 (sprzed markdown, LOG-27),
bo build-render (TeX Live) padał na runnerze z „no space left on device". Ten PR
dotyka services/render/**, więc powinien wywołać build — ale najpierw trzeba
zwolnić miejsce na runnerze, inaczej i ten build padnie.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #39.
This commit is contained in:
@@ -54,12 +54,14 @@ def esc(text: object) -> str:
|
|||||||
return out.replace(_BACKSLASH_MARK, r"\textbackslash{}")
|
return out.replace(_BACKSLASH_MARK, r"\textbackslash{}")
|
||||||
|
|
||||||
|
|
||||||
# Markdown inline: pogrubienie, kursywa, kod, linki. Kolejność w alternatywie ma
|
# Markdown inline: pogrubienie+kursywa, pogrubienie, kursywa, kod, linki. Kolejność
|
||||||
# znaczenie — `**...**` musi być PRZED `*...*`, inaczej pogrubienie rozpadłoby się
|
# w alternatywie ma znaczenie i jest OD NAJDŁUŻSZEGO znacznika: `***...***` przed
|
||||||
# na dwie kursywy. Kursywę bierzemy tylko z gwiazdek (nie z podkreśleń): `_` pada
|
# `**...**` przed `*...*`. Inaczej `***mocne***` złapałoby się jako `**` + zgubiona
|
||||||
# w nazwach typu file_name, a jako kursywa dałoby fałszywe trafienia.
|
# 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(
|
_INLINE = re.compile(
|
||||||
r"\*\*(?P<bold>.+?)\*\*"
|
r"\*\*\*(?P<bolditalic>[^\n]+?)\*\*\*"
|
||||||
|
r"|\*\*(?P<bold>[^\n]+?)\*\*"
|
||||||
r"|\*(?P<ital>[^*\n]+?)\*"
|
r"|\*(?P<ital>[^*\n]+?)\*"
|
||||||
r"|`(?P<code>[^`\n]+?)`"
|
r"|`(?P<code>[^`\n]+?)`"
|
||||||
r"|\[(?P<ltext>[^\]]+)\]\((?P<lurl>[^)]+)\)"
|
r"|\[(?P<ltext>[^\]]+)\]\((?P<lurl>[^)]+)\)"
|
||||||
@@ -73,7 +75,9 @@ def _inline(text: str) -> str:
|
|||||||
pos = 0
|
pos = 0
|
||||||
for m in _INLINE.finditer(text):
|
for m in _INLINE.finditer(text):
|
||||||
out.append(esc(text[pos:m.start()]))
|
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")) + "}")
|
out.append(r"\textbf{" + esc(m.group("bold")) + "}")
|
||||||
elif m.group("ital") is not None:
|
elif m.group("ital") is not None:
|
||||||
out.append(r"\textit{" + esc(m.group("ital")) + "}")
|
out.append(r"\textit{" + esc(m.group("ital")) + "}")
|
||||||
@@ -120,15 +124,22 @@ def markdown_to_latex(text: str) -> str:
|
|||||||
|
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
s = ln.strip()
|
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)
|
bullet = re.match(r"^[-*+]\s+(.*)$", s)
|
||||||
number = re.match(r"^\d+[.)]\s+(.*)$", s)
|
number = re.match(r"^\d+[.)]\s+(.*)$", s)
|
||||||
if not s:
|
if not s:
|
||||||
flush_para(); flush_list()
|
flush_para(); flush_list()
|
||||||
elif heading:
|
elif heading: # linia zaczyna się od kratek
|
||||||
flush_para(); flush_list()
|
flush_para(); flush_list()
|
||||||
cmd = _HEADING_CMD.get(len(heading.group(1)), "paragraph")
|
content = heading.group(2)
|
||||||
out.append(f"\\{cmd}*{{{_inline(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:
|
elif bullet:
|
||||||
flush_para()
|
flush_para()
|
||||||
if env not in (None, "itemize"):
|
if env not in (None, "itemize"):
|
||||||
|
|||||||
@@ -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
|
najbardziej krucha: błędna ucieczka nie wywala kompilacji, tylko po cichu psuje
|
||||||
treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie.
|
treść — najgorszy możliwy wynik, bo PDF powstaje i wygląda poprawnie.
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.latex import build, esc
|
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():
|
def test_empty_and_none_are_safe():
|
||||||
assert md("") == ""
|
assert md("") == ""
|
||||||
assert md(None) == ""
|
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user