release: v0.20 Codex-ready skill-driven core

This commit is contained in:
kai
2026-05-07 08:21:28 +08:00
parent 0644a68ecc
commit 68e45bcf41
45 changed files with 3005 additions and 157 deletions
+136 -14
View File
@@ -49,7 +49,7 @@ try:
Table,
TableStyle,
)
from reportlab.platypus.flowables import HRFlowable
from reportlab.platypus.flowables import Flowable, HRFlowable
except ImportError:
print("ERROR: missing reportlab. Run: uv sync", file=sys.stderr)
sys.exit(1)
@@ -279,6 +279,23 @@ def build_styles() -> StyleSheet1:
allowOrphans=0,
))
# Inline evidence footnotes placed by number_citations / finalization.
ss.add(ParagraphStyle(
name="evidence-footnote",
fontName="SrcSerif",
fontSize=7.4,
leading=10,
alignment=TA_JUSTIFY,
leftIndent=18,
firstLineIndent=-18,
spaceBefore=0,
spaceAfter=2,
textColor=colors.HexColor("#4b5563"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
# Table cell - no first-line indent, smaller font, CJK wrap for auto line break
ss.add(ParagraphStyle(
name="table-cell",
@@ -422,6 +439,23 @@ def build_styles() -> StyleSheet1:
bulletIndent=8,
))
# Ordered list. Keep references flush-left; do not prepend decorative bullets.
ss.add(ParagraphStyle(
name="ordered",
fontName="SrcSerif",
fontSize=9,
leading=13,
alignment=TA_JUSTIFY,
firstLineIndent=0,
leftIndent=0,
spaceBefore=0,
spaceAfter=4,
textColor=colors.HexColor("#374151"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
return ss
@@ -436,6 +470,28 @@ class Block:
meta: Optional[dict] = None
class FootnoteFlowable(Flowable):
"""Zero-height anchor that registers a footnote for the current PDF page."""
def __init__(self, label: str, content: str):
super().__init__()
self.label = label
self.content = content
self.width = 0
self.height = 0
def wrap(self, availWidth, availHeight):
return 0, 0
def draw(self):
page = self.canv.getPageNumber()
notes = getattr(self.canv, "_dr_footnotes", None)
if notes is None:
notes = {}
setattr(self.canv, "_dr_footnotes", notes)
notes.setdefault(page, []).append((self.label, self.content))
def parse_markdown(md_text: str) -> List[Block]:
blocks: List[Block] = []
lines = md_text.split("\n")
@@ -483,6 +539,17 @@ def parse_markdown(md_text: str) -> List[Block]:
blocks.append(Block(kind="quote", content="\n".join(quote_lines)))
continue
# Markdown footnote definition: [^1]: 原文摘录...
m = re.match(r"^\[\^([A-Za-z0-9_-]+)\]:\s*(.+)$", stripped)
if m:
blocks.append(Block(
kind="footnote",
content=m.group(2).strip(),
meta={"label": m.group(1)},
))
i += 1
continue
# Unordered list
if re.match(r"^[-*+]\s+", stripped):
while i < len(lines) and re.match(r"^[-*+]\s+", lines[i].strip()):
@@ -493,12 +560,12 @@ def parse_markdown(md_text: str) -> List[Block]:
# Ordered list
if re.match(r"^\d+\.\s+", stripped):
idx = 1
while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
item = re.sub(r"^\d+\.\s+", "", lines[i].strip())
blocks.append(Block(kind="bullet", content=f"{idx}. {item}"))
m = re.match(r"^(\d+)\.\s+(.+)$", lines[i].strip())
if not m:
break
blocks.append(Block(kind="ordered", content=f"{m.group(1)}. {m.group(2)}"))
i += 1
idx += 1
continue
# Table
@@ -518,6 +585,7 @@ def parse_markdown(md_text: str) -> List[Block]:
while i < len(lines) and lines[i].strip() and not (
lines[i].strip().startswith(("#", ">", "-", "*", "+", "!"))
or re.match(r"^\d+\.\s+", lines[i].strip())
or re.match(r"^\[\^[A-Za-z0-9_-]+\]:", lines[i].strip())
or "|" in lines[i]
):
para_lines.append(lines[i])
@@ -638,6 +706,17 @@ def md_inline_to_rl(text: str, *, add_cjk_space: bool = True) -> str:
+ ']</font></super>',
text,
)
text = re.sub(
r"<sup>(.*?)</sup>",
r"<super><font size=7>\1</font></super>",
text,
flags=re.IGNORECASE,
)
text = re.sub(
r"\[\^([A-Za-z0-9_-]+)\]",
lambda m: f"<super><font size=7>注{m.group(1)}</font></super>",
text,
)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
return text
@@ -746,9 +825,27 @@ def make_page_decorator(manifest: Manifest):
canvas.setLineWidth(0.5)
canvas.line(2 * cm, A4[1] - 1.4 * cm, A4[0] - 2 * cm, A4[1] - 1.4 * cm)
# Page-bottom evidence footnotes.
notes = getattr(canvas, "_dr_footnotes", {}).get(doc.page, [])
if notes:
width = A4[0] - 4.4 * cm
x = 2.2 * cm
y = 3.45 * cm
canvas.setStrokeColor(colors.HexColor("#cbd5e1"))
canvas.setLineWidth(0.45)
canvas.line(x, y + 0.16 * cm, x + 6.8 * cm, y + 0.16 * cm)
footnote_style = build_styles()["evidence-footnote"]
for label, content in notes:
text = f"{label}{content}"
para = Paragraph(md_inline_to_rl(text), footnote_style)
_, h = para.wrap(width, 1.3 * cm)
y -= h
para.drawOn(canvas, x, y)
y -= 0.04 * cm
# Footer: page number centered
canvas.setFont("SrcSans-Light", 8)
canvas.drawCentredString(A4[0] / 2, 1.2 * cm, f"{doc.page}")
canvas.drawCentredString(A4[0] / 2, 1.0 * cm, f"{doc.page}")
canvas.restoreState()
@@ -830,6 +927,8 @@ def collect_toc_entries(blocks: List[Block]) -> List[tuple[int, str]]:
title = b.content.strip()
if any(s in title.lower() for s in skip_titles_substr):
continue
if b.kind == "h1" and not re.match(r"^第\s*\d+\s*章\b", title):
continue
level = 1 if b.kind == "h1" else 2
entries.append((level, title))
return entries
@@ -1196,8 +1295,13 @@ def _render_generic_block(block: Block, story: list, base_dir: Path, styles: Sty
story.append(Paragraph(md_inline_to_rl(block.content), style))
elif block.kind == "quote":
story.append(Paragraph(md_inline_to_rl(block.content), styles["quote"]))
elif block.kind == "footnote":
label = block.meta.get("label") if block.meta else ""
story.append(FootnoteFlowable(str(label), block.content))
elif block.kind == "bullet":
story.append(Paragraph("" + md_inline_to_rl(block.content), styles["bullet"]))
elif block.kind == "ordered":
story.append(Paragraph(md_inline_to_rl(block.content), styles["ordered"]))
elif block.kind == "hr":
story.append(Spacer(1, 0.3 * cm))
elif block.kind == "image":
@@ -1349,6 +1453,11 @@ def build_body(
front_sections[kind] = sec
k = next_k
# If the markdown does not contain a TOC marker, still insert a generated TOC.
# This keeps PDF output stable when Phase 4 emits a clean markdown body.
if "toc" not in front_sections:
front_sections["toc"] = [Block(kind="h2", content="目录")]
# 前置件输出顺序(固定)
front_order = [
"disclaimer", # 免责声明
@@ -1408,13 +1517,26 @@ def build_body(
i = _skip_until_next_section(i + 1)
continue
# 参考文献:自动生成
# 参考文献:如果正文仍使用 [src_xxx],则从 sources.jsonl 自动生成
# 如果正文已被 number_citations.py 转为数字编号,则保留 Markdown 内的编号清单。
if kind == "references":
story.append(PageBreak())
story.extend(build_references(blocks, sources_path, styles))
j = i + 1
while j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content):
j += 1
has_src_citations = bool(collect_cited_src_ids(blocks))
has_ref_placeholder = (
j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content)
)
if has_src_citations or has_ref_placeholder:
story.extend(build_references(blocks, sources_path, styles))
while j < n and blocks[j].kind not in ("h1", "h2"):
j += 1
else:
story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"]))
while j < n and blocks[j].kind not in ("h1", "h2"):
_render_generic_block(blocks[j], story, base_dir, styles, in_summary=False)
j += 1
i = j
continue
i = j
continue
@@ -1497,7 +1619,7 @@ def main():
leftMargin=2.2 * cm,
rightMargin=2.2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
bottomMargin=4.0 * cm,
title=manifest.report_title,
author=manifest.author,
subject=manifest.type,
@@ -1510,14 +1632,14 @@ def main():
id="cover",
)
normal_frame = Frame(
2.2 * cm, 2 * cm,
A4[0] - 4.4 * cm, A4[1] - 4 * cm,
2.2 * cm, 4.0 * cm,
A4[0] - 4.4 * cm, A4[1] - 6.0 * cm,
id="normal",
)
decorator = make_page_decorator(manifest)
doc.addPageTemplates([
PageTemplate(id="cover", frames=[cover_frame]),
PageTemplate(id="normal", frames=[normal_frame], onPage=decorator),
PageTemplate(id="normal", frames=[normal_frame], onPageEnd=decorator),
])
# Assemble story