release: v0.20 Codex-ready skill-driven core
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user