|
|
|
|
|
|
|
|
import json, re, pathlib, shutil, os, math |
|
|
|
|
|
|
|
|
IMAGES_DIR_NAME = "<gpt-5_gpt-5>_images_and_tables" |
|
|
|
|
|
def find_project_root(start: pathlib.Path) -> pathlib.Path: |
|
|
cur = start.resolve() |
|
|
for p in [cur] + list(cur.parents): |
|
|
if (p / "Paper2Poster").exists() or (p / "Paper2Video").exists(): |
|
|
return p |
|
|
if (p / IMAGES_DIR_NAME).exists(): |
|
|
return p |
|
|
if (p / "posterbuilder" / "cambridge_template.tex").exists(): |
|
|
return p |
|
|
return cur |
|
|
|
|
|
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent |
|
|
ROOT_DIR = find_project_root(SCRIPT_DIR) |
|
|
TEST_DIR = ROOT_DIR / "posterbuilder" |
|
|
|
|
|
|
|
|
JSON_PATH = TEST_DIR / "contents" / "poster_content.json" |
|
|
TEMPLATE_PATH = TEST_DIR / "cambridge_template.tex" |
|
|
ARRANGEMENT_PATH = TEST_DIR / "contents" / "arrangement.json" |
|
|
CAPTION_PATH = TEST_DIR / "contents" / "figure_caption.json" |
|
|
|
|
|
OUTPUT_DIR = TEST_DIR / "latex_proj" |
|
|
OUTPUT_PATH = OUTPUT_DIR / "poster_output.tex" |
|
|
|
|
|
|
|
|
IMAGES_PARENTS = [ROOT_DIR / "Paper2Poster", ROOT_DIR] |
|
|
|
|
|
|
|
|
BEAMER_SCALE_TARGET = 1.0 |
|
|
|
|
|
TITLE_SIZE_SINGLE = r"\Huge" |
|
|
TITLE_SIZE_WRAP1 = r"\huge" |
|
|
TITLE_SIZE_WRAP2PLUS = r"\LARGE" |
|
|
|
|
|
AUTHOR_SIZE_CMD = r"\Large" |
|
|
INSTITUTE_SIZE_CMD = r"\large" |
|
|
BLOCK_TITLE_SIZE_CMD = r"\Large" |
|
|
BLOCK_BODY_SIZE_CMD = r"\large" |
|
|
CAPTION_SIZE_CMD = r"\small" |
|
|
|
|
|
|
|
|
FIG_ENLARGE_FACTOR = 1.18 |
|
|
FIG_MIN_FRAC = 0.80 |
|
|
FIG_MAX_FRAC = 0.90 |
|
|
|
|
|
|
|
|
BASE_FIG_RATIO_LIMIT = 0.58 |
|
|
TEXT_CHAR_PER_LINE = 95 |
|
|
LINE_HEIGHT_WEIGHT = 0.015 |
|
|
|
|
|
|
|
|
RIGHT_LOGO_FILENAME = "logo.png" |
|
|
RIGHT_LOGO_HEIGHT_CM = 6.0 |
|
|
RIGHT_LOGO_INNERSEP_CM= 2.0 |
|
|
RIGHT_LOGO_XSHIFT_CM = -2.0 |
|
|
RIGHT_LOGO_YSHIFT_CM = 0.0 |
|
|
|
|
|
|
|
|
MATH_BLOCK_RE = re.compile( |
|
|
r"\${1,2}.*?\${1,2}" |
|
|
r"|\\\(.+?\\\)" |
|
|
r"|\\\[(?:.|\n)+?\\\]", |
|
|
re.S |
|
|
) |
|
|
|
|
|
|
|
|
GREEK_OR_MATH_MACROS = ( |
|
|
r"alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|" |
|
|
r"mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|" |
|
|
r"Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega" |
|
|
) |
|
|
|
|
|
MATH_INLINE_MACROS = ( |
|
|
GREEK_OR_MATH_MACROS |
|
|
+ r"|partial|nabla|infty|cdot|times|pm|leq|geq|ldots|dots" |
|
|
) |
|
|
|
|
|
_MACRO_OUTSIDE_MATH_RE = re.compile( |
|
|
rf"(\\(?:{MATH_INLINE_MACROS}))" |
|
|
rf"(?:\s*[A-Za-z])?", |
|
|
) |
|
|
|
|
|
_BULLET_RE = re.compile(r"•") |
|
|
|
|
|
|
|
|
|
|
|
def wrap_math_macros_outside_math(s: str) -> str: |
|
|
""" |
|
|
目的:在“非数学环境”里遇到数学宏时,自动加上 $...$。 |
|
|
例如:\delta c -> $\delta c$ |
|
|
\tau -> $\tau$ |
|
|
已有的 $...$ / \[...\] / \(...\) 不会被二次处理(先暂存)。 |
|
|
""" |
|
|
if not s: |
|
|
return s |
|
|
|
|
|
|
|
|
stash = [] |
|
|
def _hide(m): |
|
|
stash.append(m.group(0)) |
|
|
return f"\x00M{len(stash)-1}\x00" |
|
|
s_hidden = MATH_BLOCK_RE.sub(_hide, s) |
|
|
|
|
|
|
|
|
def _wrap(m): |
|
|
return f"${m.group(0)}$" |
|
|
s_hidden = _MACRO_OUTSIDE_MATH_RE.sub(_wrap, s_hidden) |
|
|
|
|
|
|
|
|
for i, blk in enumerate(stash): |
|
|
s_hidden = s_hidden.replace(f"\x00M{i}\x00", blk) |
|
|
|
|
|
return s_hidden |
|
|
|
|
|
|
|
|
def wrap_math_macros_outside_math(s: str) -> str: |
|
|
""" |
|
|
目的:在“非数学环境”里遇到数学宏时,自动加上 $...$。 |
|
|
例如:\delta c -> $\delta c$ |
|
|
\tau -> $\tau$ |
|
|
已有的 $...$ / \[...\] / \(...\) 不会被二次处理(先暂存)。 |
|
|
""" |
|
|
if not s: |
|
|
return s |
|
|
|
|
|
|
|
|
stash = [] |
|
|
def _hide(m): |
|
|
stash.append(m.group(0)) |
|
|
return f"\x00M{len(stash)-1}\x00" |
|
|
s_hidden = MATH_BLOCK_RE.sub(_hide, s) |
|
|
|
|
|
|
|
|
def _wrap(m): |
|
|
return f"${m.group(0)}$" |
|
|
s_hidden = _MACRO_OUTSIDE_MATH_RE.sub(_wrap, s_hidden) |
|
|
|
|
|
|
|
|
for i, blk in enumerate(stash): |
|
|
s_hidden = s_hidden.replace(f"\x00M{i}\x00", blk) |
|
|
|
|
|
return s_hidden |
|
|
|
|
|
|
|
|
_BULLET_RE = re.compile(r"•") |
|
|
|
|
|
def normalize_inline_bullets(s: str) -> str: |
|
|
""" |
|
|
把 Unicode 的 • 统一替换为 LaTeX 的 \\textbullet{},并确保两侧留空格。 |
|
|
""" |
|
|
if not s: |
|
|
return s |
|
|
s = _BULLET_RE.sub(r"\\textbullet{}", s) |
|
|
|
|
|
s = re.sub(r"(?<=\S)\\textbullet\{\}(?=\S)", r" \\textbullet{} ", s) |
|
|
s = re.sub(r"\\textbullet\{\}(?=\S)", r"\\textbullet{} ", s) |
|
|
s = re.sub(r"(?<=\S)\\textbullet\{\}", r" \\textbullet{}", s) |
|
|
return s |
|
|
|
|
|
def normalize_textit_math(s: str) -> str: |
|
|
""" |
|
|
目的: |
|
|
- \textit{\tau} -> $\tau$ |
|
|
- \textit{c}(\tau) -> $c(\tau)$ |
|
|
- \textit{c} -> $c$ |
|
|
规则: |
|
|
- 先屏蔽已有数学块,避免误处理 |
|
|
- 仅把“单字母变量”或“以反斜杠开头的数学命令”从 \textit{...} 切换到数学模式 |
|
|
- 不碰 \textit{SST} 这类普通词 |
|
|
""" |
|
|
if not s: |
|
|
return s |
|
|
|
|
|
|
|
|
stash = [] |
|
|
def _hide(m): |
|
|
stash.append(m.group(0)) |
|
|
return f"\x00M{len(stash)-1}\x00" |
|
|
s = MATH_BLOCK_RE.sub(_hide, s) |
|
|
|
|
|
|
|
|
s = re.sub( |
|
|
rf"\\textit\{{\s*(\\(?:{GREEK_OR_MATH_MACROS})\b[^\}}]*)\s*\}}", |
|
|
r"$\1$", |
|
|
s |
|
|
) |
|
|
|
|
|
|
|
|
s = re.sub( |
|
|
r"\\textit\{\s*([A-Za-z])\s*\}\s*\(\s*([^()$]+?)\s*\)", |
|
|
r"$\1(\2)$", |
|
|
s |
|
|
) |
|
|
|
|
|
|
|
|
s = re.sub( |
|
|
r"\\textit\{\s*([A-Za-z])\s*\}\s*([_^]\s*(?:\{[^{}]*\}|[A-Za-z0-9]))", |
|
|
r"$\1\2$", |
|
|
s |
|
|
) |
|
|
|
|
|
|
|
|
s = re.sub( |
|
|
r"\\textit\{\s*([A-Za-z])\s*\}", |
|
|
r"$\1$", |
|
|
s |
|
|
) |
|
|
|
|
|
|
|
|
for i, blk in enumerate(stash): |
|
|
s = s.replace(f"\x00M{i}\x00", blk) |
|
|
|
|
|
return s |
|
|
|
|
|
def fix_latex_escaped_commands(s: str) -> str: |
|
|
""" |
|
|
修复由于 \t 被错误解析而导致的 LaTeX 命令丢失反斜杠问题, |
|
|
例如将 "extbf{" -> "\textbf{",并修正 "\}" -> "}"。 |
|
|
""" |
|
|
if not s: |
|
|
return s |
|
|
|
|
|
s = re.sub(r'(?<!\\)extbf\{', r'\\textbf{', s) |
|
|
s = re.sub(r'(?<!\\)extit\{', r'\\textit{', s) |
|
|
s = re.sub(r'(?<!\\)extcolor\{', r'\\textcolor{', s) |
|
|
s = re.sub(r'(?<!\\)exttt\{', r'\\texttt{', s) |
|
|
s = re.sub(r'(?<!\\)extsc\{', r'\\textsc{', s) |
|
|
s = re.sub(r'(?<!\\)extsuperscript\{', r'\\textsuperscript{', s) |
|
|
s = re.sub(r'(?<!\\)extsubscript\{', r'\\textsubscript{', s) |
|
|
|
|
|
s = s.replace("\\}", "}") |
|
|
return s |
|
|
|
|
|
|
|
|
def escape_text(s: str) -> str: |
|
|
if not s: |
|
|
return "" |
|
|
|
|
|
|
|
|
math_blocks = [] |
|
|
def store_math(m): |
|
|
math_blocks.append(m.group(0)) |
|
|
return f"\0{len(math_blocks)-1}\0" |
|
|
|
|
|
s = MATH_BLOCK_RE.sub(store_math, s) |
|
|
|
|
|
|
|
|
rep = { |
|
|
"&": r"\&", "%": r"\%", "$": r"\$", "#": r"\#", |
|
|
"_": r"\_", "{": r"\{", "}": r"\}", |
|
|
"~": r"~{}", "^": r"\^{}", |
|
|
} |
|
|
for k, v in rep.items(): |
|
|
s = s.replace(k, v) |
|
|
|
|
|
|
|
|
for i, block in enumerate(math_blocks): |
|
|
s = s.replace(f"\0{i}\0", block) |
|
|
|
|
|
return s |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def soft_wrap_title_for_logo(title: str, first_limit=68, next_limit=72) -> str: |
|
|
if not title or len(title) <= first_limit: return title |
|
|
def break_at(s: str, limit: int): |
|
|
for sep in [": ", " - ", " — ", " – "]: |
|
|
idx = s.rfind(sep, 0, limit+1) |
|
|
if idx != -1: return s[:idx+len(sep)].rstrip(), s[idx+len(sep):].lstrip() |
|
|
idx = s.rfind(" ", 0, limit+1) |
|
|
if idx == -1: idx = limit |
|
|
return s[:idx].rstrip(), s[idx:].lstrip() |
|
|
head, rest = break_at(title, first_limit); parts = [head] |
|
|
if rest: |
|
|
if len(rest) > next_limit: |
|
|
mid, tail = break_at(rest, next_limit); parts.append(mid); |
|
|
if tail: parts.append(tail) |
|
|
else: parts.append(rest) |
|
|
return r" \\ ".join(parts) |
|
|
|
|
|
def replace_command_balanced(tex: str, cmd: str, new_line: str) -> str: |
|
|
m = re.search(rf"\\{cmd}\b", tex) |
|
|
if not m: return tex |
|
|
i = m.end() |
|
|
if i < len(tex) and tex[i] == '[': |
|
|
depth = 1; i += 1 |
|
|
while i < len(tex) and depth: |
|
|
if tex[i] == '[': depth += 1 |
|
|
elif tex[i] == ']': depth -= 1 |
|
|
i += 1 |
|
|
while i < len(tex) and tex[i].isspace(): i += 1 |
|
|
if i >= len(tex) or tex[i] != '{': return tex |
|
|
start = m.start(); j = i; depth = 0; end = None |
|
|
while j < len(tex): |
|
|
if tex[j] == '{': depth += 1 |
|
|
elif tex[j] == '}': |
|
|
depth -= 1 |
|
|
if depth == 0: end = j; break |
|
|
j += 1 |
|
|
if end is None: return tex |
|
|
return tex[:start] + new_line + tex[end+1:] |
|
|
|
|
|
def format_content_to_latex(content: str) -> str: |
|
|
"""格式化正文内容,自动修复 LaTeX 命令""" |
|
|
if not content: |
|
|
return "" |
|
|
|
|
|
|
|
|
content = fix_latex_escaped_commands(content) |
|
|
|
|
|
|
|
|
content = normalize_textit_math(content) |
|
|
|
|
|
|
|
|
content = wrap_math_macros_outside_math(content) |
|
|
|
|
|
|
|
|
lines = [ln.strip() for ln in content.splitlines() if ln.strip()] |
|
|
if lines and all(ln.startswith(("-", "•")) for ln in lines): |
|
|
items = [escape_text(ln.lstrip("-• ").strip()) for ln in lines] |
|
|
return "\n".join(["\\begin{itemize}"] + [f"\\item {it}" for it in items] + ["\\end{itemize}"]) |
|
|
|
|
|
return escape_text(" ".join(lines)) |
|
|
|
|
|
|
|
|
def make_block(title: str, content: str, figures_tex: str = "") -> str: |
|
|
body = format_content_to_latex(content or "") |
|
|
if figures_tex: body = (body + "\n\n" if body else "") + figures_tex |
|
|
return f"\\begin{{block}}{{{escape_text(title or '')}}}\n{body}\n\\end{{block}}\n" |
|
|
|
|
|
|
|
|
def choose_title_size_cmd(wrapped_title: str) -> str: |
|
|
breaks = wrapped_title.count("\\\\") |
|
|
if breaks == 0: |
|
|
return TITLE_SIZE_SINGLE |
|
|
elif breaks == 1: |
|
|
return TITLE_SIZE_WRAP1 |
|
|
else: |
|
|
return TITLE_SIZE_WRAP2PLUS |
|
|
|
|
|
def build_header_from_meta(meta: dict): |
|
|
raw_title = meta.get('poster_title','') or '' |
|
|
wrapped_title = soft_wrap_title_for_logo(raw_title) |
|
|
t = f"\\title{{{escape_text(wrapped_title)}}}" |
|
|
a = f"\\author{{{escape_text(meta.get('authors',''))}}}" |
|
|
inst = f"\\institute[shortinst]{{{escape_text(meta.get('affiliations',''))}}}" |
|
|
|
|
|
return t, a, inst, wrapped_title |
|
|
|
|
|
|
|
|
def find_env_bounds(tex: str, env: str, start_pos: int): |
|
|
pat = re.compile(rf"\\(begin|end)\{{{re.escape(env)}\}}") |
|
|
depth = 0; begin_idx = None |
|
|
for m in pat.finditer(tex, start_pos): |
|
|
if m.group(1) == "begin": |
|
|
if depth == 0: begin_idx = m.start() |
|
|
depth += 1 |
|
|
else: |
|
|
depth -= 1 |
|
|
if depth == 0: |
|
|
end_idx = m.end() |
|
|
return begin_idx, end_idx |
|
|
return None, None |
|
|
|
|
|
def extract_begin_token_with_options(region: str, env: str) -> str: |
|
|
m = re.match(rf"(\\begin\{{{re.escape(env)}\}}\s*(?:\[[^\]]*\])?)", region, re.S) |
|
|
return m.group(1) if m else f"\\begin{{{env}}}" |
|
|
|
|
|
def split_even_continuous(blocks: list[str], n_cols: int) -> list[list[str]]: |
|
|
n = len(blocks); base = n // n_cols; rem = n % n_cols |
|
|
sizes = [(base + 1 if i < rem else base) for i in range(n_cols)] |
|
|
out, idx = [], 0 |
|
|
for sz in sizes: |
|
|
out.append(blocks[idx: idx+sz]); idx += sz |
|
|
return out |
|
|
|
|
|
def rebuild_first_columns_region_to_three(tex: str, blocks_latex: list[str]) -> str: |
|
|
pos_doc = tex.find(r"\begin{document}") |
|
|
if pos_doc == -1: |
|
|
raise RuntimeError("未找到 \\begin{document}") |
|
|
begin_idx, end_idx = find_env_bounds(tex, "columns", pos_doc) |
|
|
if begin_idx is None: |
|
|
raise RuntimeError("未在文档主体找到 \\begin{columns} ... \\end{columns}") |
|
|
region = tex[begin_idx:end_idx] |
|
|
begin_token = extract_begin_token_with_options(region, "columns") |
|
|
per_col_blocks = split_even_continuous(blocks_latex, 3) |
|
|
body_lines = [] |
|
|
for i in range(3): |
|
|
body_lines.append(r"\separatorcolumn") |
|
|
body_lines.append(r"\begin{column}{\colwidth}") |
|
|
if per_col_blocks[i]: body_lines.append("\n".join(per_col_blocks[i])) |
|
|
body_lines.append(r"\end{column}") |
|
|
body_lines.append(r"\separatorcolumn") |
|
|
new_region = begin_token + "\n" + "\n".join(body_lines) + "\n\\end{columns}" |
|
|
return tex[:begin_idx] + new_region + tex[end_idx:] |
|
|
|
|
|
def bump_beamerposter_scale(tex: str, target: float) -> str: |
|
|
def repl(m): |
|
|
opts = m.group(1) |
|
|
if re.search(r"scale\s*=\s*[\d.]+", opts): |
|
|
opts2 = re.sub(r"scale\s*=\s*[\d.]+", f"scale={target}", opts) |
|
|
else: |
|
|
if opts.strip().endswith(","): opts2 = opts + f"scale={target}" |
|
|
elif opts.strip()=="": opts2 = f"scale={target}" |
|
|
else: opts2 = opts + f",scale={target}" |
|
|
return f"\\usepackage[{opts2}]{{beamerposter}}" |
|
|
return re.sub(r"\\usepackage\[(.*?)\]\{beamerposter\}", repl, tex, flags=re.S) |
|
|
|
|
|
def inject_font_tweaks(tex: str, title_size_cmd: str) -> str: |
|
|
"""在 \begin{document} 前注入字号设置(标题字号可动态传入)""" |
|
|
tweaks = ( |
|
|
"\n% --- injected font tweaks ---\n" |
|
|
f"\\setbeamerfont{{title}}{{size={title_size_cmd}}}\n" |
|
|
f"\\setbeamerfont{{author}}{{size={AUTHOR_SIZE_CMD}}}\n" |
|
|
f"\\setbeamerfont{{institute}}{{size={INSTITUTE_SIZE_CMD}}}\n" |
|
|
f"\\setbeamerfont{{block title}}{{size={BLOCK_TITLE_SIZE_CMD}}}\n" |
|
|
f"\\setbeamerfont{{block body}}{{size={BLOCK_BODY_SIZE_CMD}}}\n" |
|
|
|
|
|
|
|
|
|
|
|
) |
|
|
pos_doc = tex.find(r"\begin{document}") |
|
|
return tex[:pos_doc] + tweaks + tex[pos_doc:] if pos_doc != -1 else tex + tweaks |
|
|
|
|
|
def inject_right_logo(tex: str) -> str: |
|
|
if "logo.png" in tex: |
|
|
return tex |
|
|
pos_head = tex.find(r"\addtobeamertemplate{headline}") |
|
|
node = ( |
|
|
f"\n \\node[anchor=north east, inner sep={RIGHT_LOGO_INNERSEP_CM}cm]" |
|
|
f" at ([xshift={RIGHT_LOGO_XSHIFT_CM}cm,yshift={RIGHT_LOGO_YSHIFT_CM}cm]current page.north east)\n" |
|
|
f" {{\\includegraphics[height={RIGHT_LOGO_HEIGHT_CM}cm]{{{RIGHT_LOGO_FILENAME}}}}};\n" |
|
|
) |
|
|
if pos_head != -1: |
|
|
begin_tikz = tex.find(r"\begin{tikzpicture}", pos_head) |
|
|
if begin_tikz != -1: |
|
|
b, e = find_env_bounds(tex, "tikzpicture", begin_tikz) |
|
|
if b is not None: |
|
|
region = tex[b:e] |
|
|
pos_end = region.rfind(r"\end{tikzpicture}") |
|
|
if pos_end != -1: |
|
|
insert_at = b + pos_end |
|
|
return tex[:insert_at] + node + tex[insert_at:] |
|
|
add_block = ( |
|
|
"\n% --- injected right-top logo ---\n" |
|
|
"\\addtobeamertemplate{headline}{}\n" |
|
|
"{\n" |
|
|
" \\begin{tikzpicture}[remember picture,overlay]\n" |
|
|
f" \\node[anchor=north east, inner sep={RIGHT_LOGO_INNERSEP_CM}cm]" |
|
|
f" at ([xshift={RIGHT_LOGO_XSHIFT_CM}cm,yshift={RIGHT_LOGO_YSHIFT_CM}cm]current page.north east)\n" |
|
|
f" {{\\includegraphics[height={RIGHT_LOGO_HEIGHT_CM}cm]{{{RIGHT_LOGO_FILENAME}}}}};\n" |
|
|
" \\end{tikzpicture}\n" |
|
|
"}\n" |
|
|
) |
|
|
pos_doc = tex.find(r"\begin{document}") |
|
|
return tex[:pos_doc] + add_block + tex[pos_doc:] if pos_doc != -1 else tex + add_block |
|
|
|
|
|
|
|
|
def load_arrangement_and_captions(): |
|
|
arr = json.loads(ARRANGEMENT_PATH.read_text(encoding="utf-8")) |
|
|
panels = arr.get("panels", []) |
|
|
figures = arr.get("figure_arrangement", []) |
|
|
panels_by_id = {p["panel_id"]: p for p in panels if "panel_id" in p} |
|
|
|
|
|
cap_map_full, cap_map_base = {}, {} |
|
|
if CAPTION_PATH.exists(): |
|
|
caps = json.loads(CAPTION_PATH.read_text(encoding="utf-8")) |
|
|
if isinstance(caps, dict): |
|
|
for _, v in caps.items(): |
|
|
imgp = v.get("image_path", ""); cap = v.get("caption", "") |
|
|
if imgp: |
|
|
cap_map_full[imgp] = cap |
|
|
cap_map_base[os.path.basename(imgp)] = cap |
|
|
return panels_by_id, figures, cap_map_full, cap_map_base |
|
|
|
|
|
def resolve_images_parent_dir(sample_fig_paths) -> pathlib.Path: |
|
|
for parent in IMAGES_PARENTS: |
|
|
for sp in sample_fig_paths[:10]: |
|
|
if sp: |
|
|
p = parent / sp |
|
|
if p.exists(): |
|
|
return parent |
|
|
return IMAGES_PARENTS[0] |
|
|
|
|
|
def copy_and_get_relpath(figure_path: str, out_tex_path: pathlib.Path, images_parent: pathlib.Path) -> str: |
|
|
fig_dir = out_tex_path.parent / "figures" |
|
|
fig_dir.mkdir(parents=True, exist_ok=True) |
|
|
p = pathlib.Path(figure_path) |
|
|
if p.is_absolute(): |
|
|
src = p |
|
|
else: |
|
|
if p.parts and p.parts[0] == IMAGES_DIR_NAME: |
|
|
src = images_parent / p |
|
|
else: |
|
|
src = images_parent / IMAGES_DIR_NAME / p |
|
|
dst = fig_dir / src.name |
|
|
try: |
|
|
if src.exists(): |
|
|
if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime: |
|
|
shutil.copy2(src, dst) |
|
|
except Exception: |
|
|
pass |
|
|
return str(pathlib.Path("figures") / dst.name).replace(os.sep, "/") |
|
|
|
|
|
def norm_title(s: str) -> str: |
|
|
return " ".join((s or "").lower().replace("&", "and").split()) |
|
|
|
|
|
|
|
|
CAP_PREFIX_RE = re.compile( |
|
|
r'^\s*(?:figure|fig\.?)\s*\d+(?:\s*[a-z]\)|\s*[a-z])?\s*[::\.\-–—]\s*', |
|
|
re.IGNORECASE |
|
|
) |
|
|
|
|
|
def clean_caption_prefix(cap: str) -> str: |
|
|
if not cap: return "" |
|
|
return CAP_PREFIX_RE.sub("", cap).strip() |
|
|
|
|
|
def build_figures_for_sections(sections, panels_by_id, figures, cap_full, cap_base): |
|
|
sec_name_to_idx = {norm_title(sec.get("title","")): i |
|
|
for i, sec in enumerate(sections) |
|
|
if norm_title(sec.get("title","")) != norm_title("Poster Title & Author")} |
|
|
panelid_to_secidx = {} |
|
|
for p in panels_by_id.values(): |
|
|
pname = norm_title(p.get("section_name","")) |
|
|
if pname in sec_name_to_idx: |
|
|
panelid_to_secidx[p["panel_id"]] = sec_name_to_idx[pname] |
|
|
|
|
|
|
|
|
sec_panel_height = {} |
|
|
sec_arranged_fig_height = {} |
|
|
for pid, pinfo in panels_by_id.items(): |
|
|
if pid in panelid_to_secidx: |
|
|
sidx = panelid_to_secidx[pid] |
|
|
sec_panel_height[sidx] = float(pinfo.get("height", 0.0) or 0.0) |
|
|
sec_arranged_fig_height[sidx] = 0.0 |
|
|
|
|
|
|
|
|
sec_figs = {i: [] for i in range(len(sections))} |
|
|
for fg in figures: |
|
|
pid = fg.get("panel_id") |
|
|
if pid not in panelid_to_secidx: continue |
|
|
sidx = panelid_to_secidx[pid] |
|
|
pinfo = panels_by_id.get(pid, {}) |
|
|
p_w = float(pinfo.get("width", 1.0) or 1.0) |
|
|
f_w = float(fg.get("width", 0.0) or 0.0) |
|
|
frac = 0.0 if p_w <= 0 else (f_w / p_w) * 0.95 |
|
|
width_frac = max(FIG_MIN_FRAC, min(FIG_MAX_FRAC, (frac if frac > 0 else 0.6) * FIG_ENLARGE_FACTOR)) |
|
|
fpath = fg.get("figure_path", "") |
|
|
cap_raw = cap_full.get(fpath) or cap_base.get(os.path.basename(fpath)) or "" |
|
|
cap = clean_caption_prefix(cap_raw) |
|
|
sec_figs[sidx].append({ |
|
|
"src": fpath, "caption": cap, |
|
|
"width_frac": width_frac, |
|
|
"order_y": float(fg.get("y", 0.0) or 0.0), |
|
|
"arranged_height": float(fg.get("height", 0.0) or 0.0) |
|
|
}) |
|
|
|
|
|
sec_arranged_fig_height[sidx] = sec_arranged_fig_height.get(sidx, 0.0) + float(fg.get("height", 0.0) or 0.0) |
|
|
|
|
|
for i in list(sec_figs.keys()): |
|
|
sec_figs[i].sort(key=lambda x: x["order_y"]) |
|
|
|
|
|
|
|
|
for sidx, figs in sec_figs.items(): |
|
|
if not figs: continue |
|
|
panel_h = sec_panel_height.get(sidx, 0.0) |
|
|
arranged_h = sec_arranged_fig_height.get(sidx, 0.0) |
|
|
|
|
|
content = sections[sidx].get("content","") or "" |
|
|
n_chars = len(content.strip().replace("\n"," ")) |
|
|
n_lines = math.ceil(n_chars / max(1, TEXT_CHAR_PER_LINE)) |
|
|
text_ratio = n_lines * LINE_HEIGHT_WEIGHT |
|
|
|
|
|
ratio_limit = max(0.30, BASE_FIG_RATIO_LIMIT - min(0.25, 0.12 * (n_chars/600.0))) |
|
|
|
|
|
cur_ratio = 0.0 if panel_h <= 0 else arranged_h / panel_h |
|
|
|
|
|
safety = 0.08 |
|
|
allowed = max(0.0, ratio_limit - text_ratio - safety) |
|
|
if cur_ratio > 0 and allowed > 0 and cur_ratio > allowed: |
|
|
|
|
|
scale = allowed / cur_ratio |
|
|
for it in figs: |
|
|
it["width_frac"] = max(FIG_MIN_FRAC, min(FIG_MAX_FRAC, it["width_frac"] * scale)) |
|
|
|
|
|
return sec_figs |
|
|
|
|
|
def figures_to_latex(fig_list, out_tex_path: pathlib.Path, images_parent: pathlib.Path) -> str: |
|
|
chunks = [] |
|
|
for it in fig_list: |
|
|
rel = copy_and_get_relpath(it["src"], out_tex_path, images_parent) |
|
|
w = it["width_frac"]; cap = escape_text(it["caption"] or "") |
|
|
chunks.append( |
|
|
"\\begin{figure}\n" |
|
|
+"\\centering\n" |
|
|
+f"\\includegraphics[width={w:.2f}\\linewidth]{{{rel}}}\n" |
|
|
|
|
|
+"\\end{figure}\n" |
|
|
) |
|
|
return "\n".join(chunks) |
|
|
|
|
|
|
|
|
def strip_stray_t(tex: str) -> str: |
|
|
_T_BEFORE_DOLLAR_RE = re.compile(r'\\t(?=\$)') |
|
|
if not tex: |
|
|
return tex |
|
|
return _T_BEFORE_DOLLAR_RE.sub('', tex) |
|
|
|
|
|
|
|
|
def build(): |
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
data = json.loads(JSON_PATH.read_text(encoding="utf-8")) |
|
|
meta = data.get("meta", {}) or {} |
|
|
sections_all = data.get("sections", []) or [] |
|
|
sections = [s for s in sections_all if norm_title(s.get("title","")) != norm_title("Poster Title & Author")] |
|
|
|
|
|
panels_by_id, figures, cap_full, cap_base = load_arrangement_and_captions() |
|
|
print(f"✅ Loaded arrangement and captions.") |
|
|
sample_paths = [pathlib.Path(f.get("figure_path","")) for f in figures if f.get("figure_path")] |
|
|
images_parent = resolve_images_parent_dir(sample_paths) |
|
|
|
|
|
template = TEMPLATE_PATH.read_text(encoding="utf-8") |
|
|
|
|
|
|
|
|
t, a, inst, wrapped_title = build_header_from_meta(meta) |
|
|
new_tex = template |
|
|
new_tex = replace_command_balanced(new_tex, "title", t) |
|
|
new_tex = replace_command_balanced(new_tex, "author", a) |
|
|
new_tex = replace_command_balanced(new_tex, "institute", inst) |
|
|
|
|
|
|
|
|
new_tex = bump_beamerposter_scale(new_tex, BEAMER_SCALE_TARGET) |
|
|
dyn_title_size = choose_title_size_cmd(wrapped_title) |
|
|
new_tex = inject_font_tweaks(new_tex, dyn_title_size) |
|
|
new_tex = inject_right_logo(new_tex) |
|
|
|
|
|
|
|
|
secidx_to_figs = build_figures_for_sections(sections, panels_by_id, figures, cap_full, cap_base) |
|
|
blocks = [] |
|
|
for i, sec in enumerate(sections): |
|
|
figs_tex = figures_to_latex(secidx_to_figs.get(i, []), OUTPUT_PATH, images_parent) if secidx_to_figs.get(i) else "" |
|
|
blocks.append(make_block(sec.get("title",""), sec.get("content",""), figs_tex)) |
|
|
|
|
|
|
|
|
new_tex = rebuild_first_columns_region_to_three(new_tex, blocks) |
|
|
|
|
|
cleaned_tex = new_tex |
|
|
cleaned_tex = cleaned_tex.replace(r"\{", "{") |
|
|
cleaned_tex = cleaned_tex.replace(r"\}", "}") |
|
|
|
|
|
cleaned_tex = cleaned_tex.replace(r"\\\\", r"\\") |
|
|
cleaned_tex = cleaned_tex.replace(r"\\", "\\") |
|
|
cleaned_tex = cleaned_tex.replace(r"\t\t", "\\t") |
|
|
cleaned_tex = strip_stray_t(cleaned_tex) |
|
|
|
|
|
OUTPUT_PATH.write_text(cleaned_tex, encoding="utf-8") |
|
|
print(f"✅ Wrote: {OUTPUT_PATH.relative_to(ROOT_DIR)}") |
|
|
print(f"📁 Figures copied to: {OUTPUT_DIR / 'figures'}") |
|
|
print(f"🔠 Title size chosen: {dyn_title_size}") |
|
|
|
|
|
if __name__ == "__main__": |
|
|
build() |
|
|
|