Files
tracy/manual/latex2md.py
Bartosz Taudul 745bed8477 Emit label ids suppressed by pandoc in the generated manual.
Pandoc omits a heading's {#id} when it equals the auto-generated
identifier; the manual window resolves links only from explicit
suffixes, leaving six section anchors unnavigable.
2026-10-16 00:24:10 +02:00

275 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate the Markdown user manual from the LaTeX source.
Cross-platform replacement for the previous sh/sed/awk pipeline. pandoc is
invoked as a subprocess; its Lua filter (filter.lua, next to the .tex file)
stays with pandoc.
"""
import argparse
import os
import re
import subprocess
import sys
PANDOC_MARKDOWN = 'markdown-simple_tables-multiline_tables-grid_tables+pipe_tables'
NAMES = (
('quicklook', 'A quick look at Tracy Profiler'),
('firststeps', 'First steps'),
('client', 'Client markup'),
('capturing', 'Capturing the data'),
('analyzingdata', 'Analyzing captured data'),
('tracyassist', 'Tracy Assist'),
('csvexport', 'Exporting zone statistics to CSV'),
('importingdata', 'Importing external profiling data'),
('configurationfiles', 'Configuration files'),
)
def parse_icon_header(path):
"""Return (pascal_name -> char, char -> 'Word Word') maps from the icon header."""
fa = {}
names = {}
with open(path, encoding='utf-8') as f:
for line in f:
m = re.match(
r'#define\s+ICON_FA_(\w+)\s+.*?//\s*(U\+([0-9a-fA-F]+))', line
)
if m:
parts = m.group(1).split('_')
ch = chr(int(m.group(3), 16))
fa[''.join(p.capitalize() for p in parts)] = ch
names[ch] = ' '.join(p.capitalize() for p in parts)
return fa, names
def substitute_icons(text, fa):
used = set(re.findall(r'\\fa([A-Z][a-zA-Z0-9]*)', text))
for name in sorted(used, key=lambda n: (-len(n), n)):
if name not in fa:
print(f"Warning: \\fa{name} not found in header", file=sys.stderr)
continue
ch = fa[name]
text = text.replace(f'\\fa{name}{{}}~', f'{ch} ')
text = text.replace(f'\\fa{name}{{}}', ch)
text = text.replace(f'\\fa{name}~', f'{ch} ')
text = text.replace(f'\\fa{name}', ch)
return text
def pre_sed(text):
text = text.replace('\\menu[,]', '')
text = text.replace('\\keys', '')
text = text.replace('\\ctrl', 'Ctrl')
text = text.replace('\\shift', 'Shift')
text = text.replace('\\Alt', 'Alt')
text = text.replace('\\del', 'Delete')
return text
def post_sed(text):
for m in ('LMB', 'MMB', 'RMB', 'Scroll'):
text = text.replace(f'\\{m}{{}}~', '')
text = text.replace('\\textsigma', 'σ')
for l in 'abc':
text = text.replace(f'@\\circled{{{l}}}@', f'({l})')
for l in 'abc':
text = text.replace(f'\\circled{{{l}}}', f'({l})')
text = text.replace('@\\ldots@', '…')
text = re.sub(r',?escapeinside=\{\}\{\}', '', text)
for label, name in NAMES:
text = text.replace(f'\\nameref{{{label}}}', name)
return text
def bclogo2quote(text):
"""Convert \\begin{bclogo} admonition blocks to \\begin{quote}\\textbf{PREFIX:...}."""
prefix = {'bcbombe': 'IMPORTANT', 'bcattention': 'CAUTION', 'bclampe': 'TIP'}
out = []
in_bclogo = False
bclogo_type = ''
for line in text.split('\n'):
if not in_bclogo and r'\begin{bclogo}[' in line:
in_bclogo = True
bclogo_type = ''
continue
if in_bclogo and 'logo=' in line:
if r'\bcbombe' in line:
bclogo_type = 'bcbombe'
elif r'\bcattention' in line:
bclogo_type = 'bcattention'
elif r'\bclampe' in line:
bclogo_type = 'bclampe'
elif r'\bcquestion' in line:
bclogo_type = 'bcquestion'
continue
if in_bclogo and ('noborder' in line or 'couleur' in line):
continue
if in_bclogo:
title = re.sub(r'\}.*$', '', re.sub(r'^[ \t]*\]?\{', '', line))
out.append(f"\\begin{{quote}}\\textbf{{{prefix.get(bclogo_type, 'NOTE')}:{title}}}\\par")
in_bclogo = False
continue
if r'\end{bclogo}' in line:
out.append('\\end{quote}')
continue
out.append(line)
return '\n'.join(out)
def run_pandoc(pandoc, filter_path, tex):
args = [
pandoc,
'--wrap=none',
'--reference-location=block',
'--number-sections',
'-f', 'latex',
'-t', PANDOC_MARKDOWN,
'-s',
'-L', filter_path,
'-',
]
proc = subprocess.run(args, input=tex.encode('utf-8'), capture_output=True)
if proc.returncode != 0:
sys.stderr.write(proc.stderr.decode('utf-8', 'replace'))
sys.exit(proc.returncode)
return proc.stdout.decode('utf-8')
def tablecaption(text):
"""Pandoc emits table captions as ": ..." lines; italicize them instead."""
out = []
incap = False
for line in text.split('\n'):
if not incap and line.startswith(': '):
incap = True
line = '_' + line[2:]
if incap and not line.endswith('\\'):
line = re.sub(r' \{#[^{}]+\}$', '', line)
out.append(line + '_')
incap = False
continue
out.append(line)
return '\n'.join(out)
def admonitions(text):
for kind in ('IMPORTANT', 'TIP', 'CAUTION', 'NOTE'):
text = re.sub(
rf'^> \*\*{kind}:([^*]*)\*\*',
f'> [!{kind}]\n> **\\1**',
text,
flags=re.M,
)
return text
def auto_id(title):
return '-'.join(''.join(c.lower() if c.isalnum() else ' ' for c in title).split())
def force_heading_ids(text, md):
"""Pandoc omits a `{#id}` suffix equal to the heading's auto-generated
identifier; TracyManualData resolves anchor links only from explicit suffixes."""
commands = re.findall(
r'\\(?:part|chapter|section|subsection|subsubsection|subparagraph|paragraph)\*?'
r'\{((?:[^{}]|\{[^{}]*\})*)\}(?:\s*\\label\{([^{}]+)\})?',
text,
)
headings = []
in_fence = False
for line in md.split('\n'):
if line.lstrip().startswith('```'):
in_fence = not in_fence
elif not in_fence and re.match(r'^#{1,6} ', line):
headings.append(line)
if len(headings) != len(commands):
return md
stamp = []
for (_, label), heading in zip(commands, headings):
m = re.match(r'^#{1,6} (.*)$', heading)
stamp.append(bool(label and m and '{' not in m.group(1) and auto_id(m.group(1)) == label))
out = []
in_fence = False
hi = 0
for line in md.split('\n'):
if line.lstrip().startswith('```'):
in_fence = not in_fence
elif not in_fence:
m = re.match(r'^(#{1,6}) +(.*)$', line)
if m:
if stamp[hi]:
line = f'{m.group(1)} {m.group(2)} {{#{commands[hi][1]}}}'
hi += 1
out.append(line)
return '\n'.join(out)
def icon_explain(text, names):
"""Append an icon legend to each top-level section containing FA icons."""
lines = text.split('\n')
chunk_starts = [i for i, line in enumerate(lines) if line.startswith('#')]
if chunk_starts and chunk_starts[0] > 0:
chunk_starts.insert(0, 0)
out = []
for ci, start in enumerate(chunk_starts):
end = chunk_starts[ci + 1] if ci + 1 < len(chunk_starts) else len(lines)
chunk = lines[start:end]
icons = []
seen = set()
for line in chunk:
for ch in line:
cp = ord(ch)
if 0xE000 <= cp <= 0xF8FF and ch not in seen:
seen.add(ch)
icons.append(ch)
out.extend(chunk)
if icons:
out.append('')
out.append('-----')
out.append('')
for ch in icons:
out.append(f"{ch} - {names.get(ch, f'Unknown(U+{ord(ch):04X})')} icon")
out.append('')
return '\n'.join(out)
def main():
ap = argparse.ArgumentParser(description='Generate the Markdown user manual')
ap.add_argument('--tex', required=True, help='path to tracy.tex')
ap.add_argument('--out', required=True, help='path of the generated tracy.md')
ap.add_argument('--pandoc', required=True, help='path to the pandoc executable')
ap.add_argument('--icons', required=True, help='path to IconsFontAwesome7.h')
args = ap.parse_args()
fa, names = parse_icon_header(args.icons)
with open(args.tex, encoding='utf-8') as f:
text = f.read()
text = pre_sed(text)
text = substitute_icons(text, fa)
text = post_sed(text)
text = bclogo2quote(text)
md = run_pandoc(
args.pandoc,
os.path.join(os.path.dirname(os.path.abspath(args.tex)), 'filter.lua'),
text,
)
md = tablecaption(md)
md = force_heading_ids(text, admonitions(md))
md = icon_explain(md, names)
out_dir = os.path.dirname(os.path.abspath(args.out))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
with open(args.out, 'w', encoding='utf-8', newline='\n') as f:
f.write(md)
if __name__ == '__main__':
main()