Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dffafce8b9 | ||
|
|
406dd42571 | ||
|
|
3dcd469bc0 | ||
|
|
966a94cad8 | ||
|
|
c9c5f81d49 | ||
|
|
5a3546b3ac | ||
|
|
124024a547 | ||
|
|
b87f94551b | ||
|
|
50d7fb4c7d | ||
|
|
04f36524b1 | ||
|
|
f49d1245a1 | ||
|
|
64af1f5165 | ||
|
|
7eae7fba00 | ||
|
|
5c2441464d |
@@ -18,6 +18,8 @@ backend/.env
|
||||
# 运行期生成的 SQLite 索引(vault 下的 Markdown 测试数据需提交)
|
||||
backend/data/*.db*
|
||||
backend/data/credentials/
|
||||
# 运行期导出的 HTML/PDF/DOCX 产物(不提交)
|
||||
backend/data/exports/
|
||||
backend/data/logs/
|
||||
# 阶段验收笔记(验收用,不提交)
|
||||
backend/data/vault/验收/
|
||||
|
||||
@@ -25,6 +25,7 @@ class Settings:
|
||||
vault_path: Path
|
||||
attachments_path: Path
|
||||
benchmark_datasets_path: Path
|
||||
exports_path: Path
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -45,4 +46,5 @@ def get_settings() -> Settings:
|
||||
benchmark_datasets_path=Path(
|
||||
os.getenv("APP_BENCHMARK_DATASETS_PATH", str(data_dir / "benchmarks"))
|
||||
),
|
||||
exports_path=Path(os.getenv("APP_EXPORTS_PATH", str(data_dir / "exports"))),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,14 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SecretStr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from app.request_overrides import RequestOverride
|
||||
|
||||
|
||||
@@ -1307,3 +1314,88 @@ class BenchmarkReport(Contract):
|
||||
cases: list[RAGCaseResult] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
|
||||
|
||||
# Export(多格式文档导出)
|
||||
class ExportStatus(str, Enum):
|
||||
queued = "queued"
|
||||
running = "running"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class ExportFormat(str, Enum):
|
||||
html = "html"
|
||||
pdf = "pdf"
|
||||
docx = "docx"
|
||||
|
||||
|
||||
class ExportSourceType(str, Enum):
|
||||
note = "note"
|
||||
markdown = "markdown"
|
||||
|
||||
|
||||
class ExportSource(Contract):
|
||||
"""导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。"""
|
||||
|
||||
type: ExportSourceType
|
||||
note_id: str | None = None
|
||||
markdown: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source(self) -> "ExportSource":
|
||||
if self.type == ExportSourceType.note and not self.note_id:
|
||||
raise ValueError("note source requires note_id")
|
||||
if self.type == ExportSourceType.markdown and not self.markdown:
|
||||
raise ValueError("markdown source requires markdown")
|
||||
return self
|
||||
|
||||
|
||||
class ExportOptions(Contract):
|
||||
theme_id: str = "light"
|
||||
include_title: bool = True
|
||||
include_metadata: bool = False
|
||||
page_size: str = "A4"
|
||||
code_theme: str = "github-light"
|
||||
|
||||
|
||||
class ExportRequest(Contract):
|
||||
source: ExportSource
|
||||
format: ExportFormat
|
||||
options: ExportOptions = Field(default_factory=ExportOptions)
|
||||
|
||||
|
||||
class ExportProgress(Contract):
|
||||
phase: str
|
||||
current: int
|
||||
total: int
|
||||
percent: float | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class ExportFile(Contract):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
size: int
|
||||
sha256: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class ExportJob(Contract):
|
||||
job_id: str
|
||||
status: ExportStatus
|
||||
format: ExportFormat
|
||||
progress: ExportProgress | None = None
|
||||
file: ExportFile | None = None
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
created_at: datetime
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class ExportJobListResponse(Contract):
|
||||
items: list[ExportJob] = Field(default_factory=list)
|
||||
page: PageMeta = Field(default_factory=PageMeta)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Export Service:多格式文档导出(首批 HTML)。
|
||||
|
||||
模块划分:
|
||||
- document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
|
||||
- markdown.py mistune → Document AST 解析
|
||||
- exporters/html.py HtmlExporter(Document AST → HTML5)
|
||||
- service.py 导出任务注册表、后台执行、取消与文件生命周期
|
||||
"""
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Document AST:导出器的内部中间表示(Internal Protocol,不放入 contracts.py)。
|
||||
|
||||
契约 §10.3 规定节点用稳定判别字段 node_id / type / attributes / children / text,
|
||||
类型专有信息统一放 attributes(如 heading 的 level、link 的 href、image 的 src)。
|
||||
导出器据此递归渲染,对无法表示的节点记 warning,不静默丢弃。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
|
||||
|
||||
class DocumentNode(BaseModel):
|
||||
"""递归文档节点;type 取契约 §10.3 首批 node type 之一。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: str
|
||||
node_id: str
|
||||
attributes: dict[str, Any] = Field(default_factory=dict)
|
||||
children: list["DocumentNode"] = Field(default_factory=list)
|
||||
text: str = ""
|
||||
|
||||
|
||||
class Document(DocumentNode):
|
||||
"""根节点,type 固定为 document。"""
|
||||
|
||||
type: str = "document"
|
||||
|
||||
|
||||
class DocumentExporter(Protocol):
|
||||
"""导出器协议(契约 §10.3):把 Document AST 渲染为指定格式的产物。"""
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> "ExportResult": ...
|
||||
|
||||
|
||||
class ExportResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: bytes
|
||||
mime_type: str
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1 @@
|
||||
"""Export 渲染器:Document AST → 具体格式产物。"""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""导出器共享工具:URL 协议校验与占位 warning 文案。
|
||||
|
||||
html / pdf / docx 三个导出器共用同一套安全规则,避免各写一份导致行为漂移。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
|
||||
ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
|
||||
|
||||
MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
|
||||
RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
|
||||
# PDF/DOCX 暂不支持静态渲染函数图像,统一回退源码占位
|
||||
PLOT_PLACEHOLDER_WARNING = "函数图像:该格式暂不支持静态渲染,已保留为源码占位"
|
||||
|
||||
|
||||
def safe_url(url: str) -> str | None:
|
||||
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return None
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme and scheme not in ALLOWED_URL_SCHEMES:
|
||||
return None
|
||||
return url
|
||||
|
||||
|
||||
def format_meta_value(value: object) -> str:
|
||||
"""把元数据值转成可读文本:datetime 转 ISO、列表用逗号连接。"""
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""DocxExporter:Document AST → DOCX(python-docx)。
|
||||
|
||||
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
||||
function_plot 与 mermaid 保留源码占位并记 warning。中文字体通过 Normal 样式挂载
|
||||
w:eastAsia=宋体,保证 Word 打开时中文正常显示;bold/italic 由 Word 原生渲染。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from docx import Document as DocxDocument
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Inches, Mm, Pt, RGBColor
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.document import Document, DocumentNode, ExportResult
|
||||
from app.export.exporters._common import (
|
||||
MERMAID_WARNING,
|
||||
PLOT_PLACEHOLDER_WARNING,
|
||||
RAW_HTML_WARNING,
|
||||
format_meta_value,
|
||||
safe_url,
|
||||
)
|
||||
|
||||
_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
_HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
|
||||
|
||||
|
||||
def _plain_text(children: list[DocumentNode]) -> str:
|
||||
"""递归拼接行内节点的纯文本,供标题/链接文字等需要纯文本处使用。"""
|
||||
parts: list[str] = []
|
||||
for child in children:
|
||||
if child.type == "text":
|
||||
parts.append(child.text)
|
||||
elif child.children:
|
||||
parts.append(_plain_text(child.children))
|
||||
elif child.text:
|
||||
parts.append(child.text)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class DocxExporter:
|
||||
"""实现 DocumentExporter:递归渲染 Document AST 为 DOCX 字节流。"""
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
self._doc = DocxDocument()
|
||||
self._configure_normal_style()
|
||||
self._configure_page(options)
|
||||
warnings: list[str] = []
|
||||
|
||||
self._render_header(document, options, warnings)
|
||||
self._render_children(document.children, warnings)
|
||||
|
||||
buf = BytesIO()
|
||||
self._doc.save(buf)
|
||||
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
|
||||
return self.render(document, options)
|
||||
|
||||
def _configure_normal_style(self) -> None:
|
||||
"""Normal 样式挂载 CJK 字体;拉丁用 Calibri,中文用宋体。"""
|
||||
style = self._doc.styles["Normal"]
|
||||
style.font.name = "Calibri"
|
||||
style.font.size = Pt(11)
|
||||
rfonts = style.element.get_or_add_rPr().get_or_add_rFonts()
|
||||
rfonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
def _configure_page(self, options: ExportOptions) -> None:
|
||||
section = self._doc.sections[0]
|
||||
size = (options.page_size or "A4").lower()
|
||||
if size == "a4":
|
||||
section.page_width = Mm(210)
|
||||
section.page_height = Mm(297)
|
||||
elif size == "letter":
|
||||
section.page_width = Inches(8.5)
|
||||
section.page_height = Inches(11)
|
||||
|
||||
# --- 文档头部 ---
|
||||
def _render_header(self, document: Document, options: ExportOptions, warnings: list[str]) -> None:
|
||||
title = str(document.attributes.get("title") or "")
|
||||
if options.include_title and title:
|
||||
p = self._doc.add_paragraph()
|
||||
run = p.add_run(title)
|
||||
run.bold = True
|
||||
run.font.size = Pt(22)
|
||||
p.paragraph_format.space_after = Pt(12)
|
||||
if options.include_metadata:
|
||||
metadata = document.attributes.get("metadata")
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
p = self._doc.add_paragraph()
|
||||
run = p.add_run(f"{key}: {format_meta_value(value)}")
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
|
||||
|
||||
# --- 块级 ---
|
||||
def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> None:
|
||||
for child in children:
|
||||
self._render_block(child, warnings)
|
||||
|
||||
def _render_block(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
handler = getattr(self, f"_block_{node.type}", None)
|
||||
if handler is not None:
|
||||
handler(node, warnings)
|
||||
else:
|
||||
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
|
||||
|
||||
def _block_heading(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
level = max(1, min(6, int(node.attributes.get("level", 1))))
|
||||
p = self._doc.add_paragraph()
|
||||
run = p.add_run(_plain_text(node.children))
|
||||
run.bold = True
|
||||
run.font.size = Pt(_HEADING_SIZES[level])
|
||||
p.paragraph_format.space_before = Pt(14 if level <= 2 else 10)
|
||||
p.paragraph_format.space_after = Pt(6)
|
||||
|
||||
def _block_paragraph(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
p = self._doc.add_paragraph()
|
||||
self._render_inline(p, node.children, warnings)
|
||||
|
||||
def _block_blockquote(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
p = self._doc.add_paragraph()
|
||||
self._render_inline(p, node.children, warnings)
|
||||
p.paragraph_format.left_indent = Pt(16)
|
||||
for run in p.runs:
|
||||
run.font.color.rgb = RGBColor(0x57, 0x60, 0x6A)
|
||||
|
||||
def _block_list(self, node: DocumentNode, warnings: list[str], level: int = 0) -> None:
|
||||
ordered = bool(node.attributes.get("ordered"))
|
||||
for index, item in enumerate(node.children, start=1):
|
||||
self._block_list_item(item, warnings, ordered, index, level)
|
||||
|
||||
def _block_list_item(
|
||||
self,
|
||||
item: DocumentNode,
|
||||
warnings: list[str],
|
||||
ordered: bool,
|
||||
index: int,
|
||||
level: int,
|
||||
) -> None:
|
||||
if item.attributes.get("task"):
|
||||
marker = "☑ " if item.attributes.get("checked") else "☐ "
|
||||
else:
|
||||
marker = f"{index}. " if ordered else "• "
|
||||
indent = Pt(18 + 18 * level)
|
||||
first = True
|
||||
for child in item.children:
|
||||
if child.type == "list":
|
||||
self._block_list(child, warnings, level + 1)
|
||||
continue
|
||||
if child.type == "paragraph":
|
||||
p = self._doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = indent
|
||||
if first:
|
||||
self._add_run(p, marker)
|
||||
first = False
|
||||
self._render_inline(p, child.children, warnings)
|
||||
elif child.children:
|
||||
# 直接行内子节点:拼进一个段落
|
||||
p = self._doc.add_paragraph()
|
||||
p.paragraph_format.left_indent = indent
|
||||
if first:
|
||||
self._add_run(p, marker)
|
||||
first = False
|
||||
self._render_inline(p, child.children, warnings)
|
||||
else:
|
||||
self._render_block(child, warnings)
|
||||
first = False
|
||||
|
||||
def _block_table(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
rows = node.children
|
||||
ncols = max((len(r.children) for r in rows), default=0)
|
||||
if not rows or ncols == 0:
|
||||
return
|
||||
table = self._doc.add_table(rows=len(rows), cols=ncols)
|
||||
table.style = "Table Grid"
|
||||
for ri, row in enumerate(rows):
|
||||
head = bool(row.attributes.get("head"))
|
||||
for ci in range(ncols):
|
||||
cell = table.cell(ri, ci)
|
||||
p = cell.paragraphs[0]
|
||||
if ci < len(row.children):
|
||||
self._render_inline(p, row.children[ci].children, warnings, bold=head)
|
||||
|
||||
def _block_code_block(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
lines = node.text.split("\n")
|
||||
p = self._doc.add_paragraph()
|
||||
self._shade_paragraph(p)
|
||||
p.paragraph_format.left_indent = Pt(8)
|
||||
p.paragraph_format.right_indent = Pt(8)
|
||||
p.paragraph_format.space_before = Pt(6)
|
||||
p.paragraph_format.space_after = Pt(8)
|
||||
for i, line in enumerate(lines):
|
||||
run = p.add_run(line)
|
||||
run.font.name = "Consolas"
|
||||
run.font.size = Pt(10)
|
||||
if i < len(lines) - 1:
|
||||
run.add_break()
|
||||
|
||||
def _block_thematic_break(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
p = self._doc.add_paragraph()
|
||||
pPr = p._p.get_or_add_pPr()
|
||||
pBdr = OxmlElement("w:pBdr")
|
||||
bottom = OxmlElement("w:bottom")
|
||||
bottom.set(qn("w:val"), "single")
|
||||
bottom.set(qn("w:sz"), "6")
|
||||
bottom.set(qn("w:space"), "1")
|
||||
bottom.set(qn("w:color"), "D0D7DE")
|
||||
pBdr.append(bottom)
|
||||
pPr.append(pBdr)
|
||||
|
||||
def _block_mermaid(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
warnings.append(MERMAID_WARNING)
|
||||
self._block_code_block(node, warnings)
|
||||
|
||||
def _block_function_plot(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
warnings.append(PLOT_PLACEHOLDER_WARNING)
|
||||
self._block_code_block(node, warnings)
|
||||
|
||||
def _block_math_block(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
p = self._doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
p.add_run(f"$${node.text}$$")
|
||||
|
||||
def _block_html_block(self, node: DocumentNode, warnings: list[str]) -> None:
|
||||
# 原始 HTML 不可信,按纯文本保留正文
|
||||
warnings.append(RAW_HTML_WARNING)
|
||||
self._doc.add_paragraph(node.text)
|
||||
|
||||
# --- 行内(写入 run) ---
|
||||
def _render_inline(
|
||||
self,
|
||||
paragraph,
|
||||
children: list[DocumentNode],
|
||||
warnings: list[str],
|
||||
bold: bool = False,
|
||||
italic: bool = False,
|
||||
) -> None:
|
||||
for child in children:
|
||||
self._render_inline_node(paragraph, child, warnings, bold, italic)
|
||||
|
||||
def _render_inline_node(
|
||||
self, paragraph, node: DocumentNode, warnings: list[str], bold: bool, italic: bool
|
||||
) -> None:
|
||||
t = node.type
|
||||
if t == "text":
|
||||
self._add_run(paragraph, node.text, bold=bold, italic=italic)
|
||||
elif t == "strong":
|
||||
self._render_inline(paragraph, node.children, warnings, bold=True, italic=italic)
|
||||
elif t == "emphasis":
|
||||
self._render_inline(paragraph, node.children, warnings, bold=bold, italic=True)
|
||||
elif t == "codespan":
|
||||
self._add_run(paragraph, node.text, code=True)
|
||||
elif t == "link":
|
||||
inner = _plain_text(node.children)
|
||||
href = str(node.attributes.get("href") or "")
|
||||
safe_href = safe_url(href)
|
||||
if safe_href is None:
|
||||
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
|
||||
self._render_inline(paragraph, node.children, warnings, bold, italic)
|
||||
else:
|
||||
self._add_hyperlink(paragraph, safe_href, inner)
|
||||
elif t == "image":
|
||||
src = str(node.attributes.get("src") or "")
|
||||
alt = str(node.attributes.get("alt") or "")
|
||||
if safe_url(src) is None:
|
||||
warnings.append(f"图片地址不安全,已跳过:{src!r}")
|
||||
else:
|
||||
warnings.append("图片未内嵌到 DOCX,已用替代文本表示")
|
||||
if alt:
|
||||
self._add_run(paragraph, alt)
|
||||
elif t == "math_inline":
|
||||
self._add_run(paragraph, f"\\({node.text}\\)")
|
||||
elif t == "linebreak":
|
||||
self._add_run(paragraph, "").add_break()
|
||||
else:
|
||||
warnings.append(f"无法表示的行内节点已跳过:{t}")
|
||||
|
||||
def _add_run(self, paragraph, text: str, bold: bool = False, italic: bool = False, code: bool = False):
|
||||
run = paragraph.add_run(text)
|
||||
run.bold = bold
|
||||
run.italic = italic
|
||||
if code:
|
||||
run.font.name = "Consolas"
|
||||
run.font.size = Pt(10)
|
||||
return run
|
||||
|
||||
def _add_hyperlink(self, paragraph, url: str, text: str) -> None:
|
||||
"""写入可点击的超链接 run(python-docx 无公开 API,需手写 w:hyperlink)。"""
|
||||
part = paragraph.part
|
||||
r_id = part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
|
||||
hyperlink = OxmlElement("w:hyperlink")
|
||||
hyperlink.set(qn("r:id"), r_id)
|
||||
run = OxmlElement("w:r")
|
||||
rPr = OxmlElement("w:rPr")
|
||||
rFonts = OxmlElement("w:rFonts")
|
||||
rFonts.set(qn("w:ascii"), "Calibri")
|
||||
rFonts.set(qn("w:hAnsi"), "Calibri")
|
||||
rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
rPr.append(rFonts)
|
||||
color = OxmlElement("w:color")
|
||||
color.set(qn("w:val"), "0969DA")
|
||||
rPr.append(color)
|
||||
u = OxmlElement("w:u")
|
||||
u.set(qn("w:val"), "single")
|
||||
rPr.append(u)
|
||||
run.append(rPr)
|
||||
t = OxmlElement("w:t")
|
||||
t.text = text
|
||||
t.set(qn("xml:space"), "preserve")
|
||||
run.append(t)
|
||||
hyperlink.append(run)
|
||||
paragraph._p.append(hyperlink)
|
||||
|
||||
def _shade_paragraph(self, paragraph, fill: str = "F2F2F2") -> None:
|
||||
"""给段落加浅灰底纹,用于代码块占位。"""
|
||||
pPr = paragraph._p.get_or_add_pPr()
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), fill)
|
||||
pPr.append(shd)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。
|
||||
|
||||
mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot
|
||||
解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以
|
||||
EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.document import Document, DocumentNode, ExportResult
|
||||
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
|
||||
|
||||
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
|
||||
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
|
||||
|
||||
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
|
||||
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
|
||||
|
||||
# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程
|
||||
_MAX_FUNCTION_PLOTS = 16
|
||||
# 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块
|
||||
# × 多表达式 × 深表达式)在采样求值时长时间占满 CPU
|
||||
_MAX_TOTAL_PLOT_NODES = 8000
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str | None:
|
||||
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return None
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme and scheme not in _ALLOWED_URL_SCHEMES:
|
||||
return None
|
||||
return url
|
||||
|
||||
_BASE_CSS = """
|
||||
body { margin: 0; background: #f6f7f9; color: #1f2328; font: 15px/1.7 -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif; }
|
||||
article { max-width: 860px; margin: 0 auto; padding: 40px 48px; background: #fff; }
|
||||
article.theme-dark { background: #0d1117; color: #c9d1d9; }
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.4em 0 0.6em; }
|
||||
h1.title { margin-top: 0; }
|
||||
p { margin: 0.6em 0; }
|
||||
a { color: #0969da; }
|
||||
code { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 0.9em; background: #f0f1f3; padding: 0.15em 0.35em; border-radius: 3px; }
|
||||
pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: auto; }
|
||||
pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; }
|
||||
pre code { background: none; padding: 0; }
|
||||
pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; }
|
||||
figure.function-plot { margin: 1em 0; text-align: center; }
|
||||
figure.function-plot svg { max-width: 100%; height: auto; }
|
||||
blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; }
|
||||
img { max-width: 100%; }
|
||||
table { border-collapse: collapse; margin: 0.8em 0; }
|
||||
th, td { border: 1px solid #d0d7de; padding: 6px 12px; }
|
||||
th { background: #f6f8fa; }
|
||||
dl.metadata { font-size: 0.85em; color: #57606a; border-top: 1px solid #eaeef2; border-bottom: 1px solid #eaeef2; padding: 0.6em 0; }
|
||||
dl.metadata dt { display: inline; font-weight: 600; margin-right: 0.4em; }
|
||||
dl.metadata dd { display: inline; margin: 0 1.2em 0 0; }
|
||||
.math, .math-block { overflow-x: auto; padding: 0.4em 0; }
|
||||
.task-list-item { list-style: none; }
|
||||
.task-list-item input { margin-right: 0.4em; }
|
||||
hr { border: none; border-top: 1px solid #d0d7de; margin: 1.4em 0; }
|
||||
""".strip()
|
||||
|
||||
|
||||
class HtmlExporter:
|
||||
"""实现 DocumentExporter:递归渲染 Document AST 为完整 HTML5 文档。"""
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
self._options = options
|
||||
self._plot_count = 0
|
||||
self._plot_nodes = 0
|
||||
self._plot_renderer = FunctionPlotStaticRenderer()
|
||||
warnings: list[str] = []
|
||||
body = self._render_children(document.children, warnings)
|
||||
content = self._assemble(document, options, body, warnings)
|
||||
return ExportResult(
|
||||
content=content.encode("utf-8"), mime_type="text/html", warnings=warnings
|
||||
)
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
|
||||
return self.render(document, options)
|
||||
|
||||
def _assemble(
|
||||
self, document: Document, options: ExportOptions, body: str, warnings: list[str]
|
||||
) -> str:
|
||||
title = str(document.attributes.get("title") or "")
|
||||
parts = [
|
||||
"<!doctype html>",
|
||||
'<html lang="zh-CN">',
|
||||
"<head>",
|
||||
'<meta charset="utf-8">',
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
]
|
||||
if title:
|
||||
parts.append(f"<title>{html.escape(title)}</title>")
|
||||
parts.append(f"<style>{_BASE_CSS}</style>")
|
||||
parts.append("</head>")
|
||||
parts.append("<body>")
|
||||
parts.append(f'<article class="theme-{html.escape(options.theme_id)}">')
|
||||
if options.include_title and title:
|
||||
parts.append(f'<h1 class="title">{html.escape(title)}</h1>')
|
||||
if options.include_metadata:
|
||||
metadata = document.attributes.get("metadata")
|
||||
if metadata:
|
||||
parts.append(self._render_metadata(metadata))
|
||||
parts.append(body)
|
||||
parts.append("</article>")
|
||||
parts.append("</body>")
|
||||
parts.append("</html>")
|
||||
return "\n".join(parts) + "\n"
|
||||
|
||||
def _render_metadata(self, metadata: dict) -> str:
|
||||
entries = ["<dl", ' class="metadata">']
|
||||
for key, value in metadata.items():
|
||||
entries.append(f"<dt>{html.escape(str(key))}</dt>")
|
||||
entries.append(f"<dd>{html.escape(self._fmt_meta_value(value))}</dd>")
|
||||
entries.append("</dl>")
|
||||
return "".join(entries)
|
||||
|
||||
@staticmethod
|
||||
def _fmt_meta_value(value: object) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
def _render_children(self, children: list[DocumentNode], warnings: list[str]) -> str:
|
||||
return "".join(self._render_node(child, warnings) for child in children)
|
||||
|
||||
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
handler = getattr(self, f"_render_{node.type}", None)
|
||||
if handler is not None:
|
||||
return handler(node, warnings)
|
||||
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
|
||||
return ""
|
||||
|
||||
# --- 块级 ---
|
||||
def _render_heading(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
level = max(1, min(6, int(node.attributes.get("level", 1))))
|
||||
return f"<h{level}>{self._render_children(node.children, warnings)}</h{level}>"
|
||||
|
||||
def _render_paragraph(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<p>{self._render_children(node.children, warnings)}</p>"
|
||||
|
||||
def _render_blockquote(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<blockquote>{self._render_children(node.children, warnings)}</blockquote>"
|
||||
|
||||
def _render_list(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
tag = "ol" if node.attributes.get("ordered") else "ul"
|
||||
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
|
||||
|
||||
def _render_list_item(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
inner = self._render_children(node.children, warnings)
|
||||
if node.attributes.get("task"):
|
||||
checked = " checked" if node.attributes.get("checked") else ""
|
||||
return (
|
||||
'<li class="task-list-item">'
|
||||
f'<input type="checkbox" disabled{checked}>{inner}</li>'
|
||||
)
|
||||
return f"<li>{inner}</li>"
|
||||
|
||||
def _render_table(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
rows = node.children
|
||||
head_rows = [r for r in rows if r.attributes.get("head")]
|
||||
body_rows = [r for r in rows if not r.attributes.get("head")]
|
||||
parts = ["<table>"]
|
||||
if head_rows:
|
||||
parts.append("<thead>")
|
||||
parts.extend(self._render_node(r, warnings) for r in head_rows)
|
||||
parts.append("</thead>")
|
||||
if body_rows:
|
||||
parts.append("<tbody>")
|
||||
parts.extend(self._render_node(r, warnings) for r in body_rows)
|
||||
parts.append("</tbody>")
|
||||
parts.append("</table>")
|
||||
return "".join(parts)
|
||||
|
||||
def _render_table_row(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<tr>{self._render_children(node.children, warnings)}</tr>"
|
||||
|
||||
def _render_table_cell(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
tag = "th" if node.attributes.get("head") else "td"
|
||||
return f"<{tag}>{self._render_children(node.children, warnings)}</{tag}>"
|
||||
|
||||
def _render_code_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
lang = str(node.attributes.get("language") or "")
|
||||
code = html.escape(node.text)
|
||||
lang_cls = f' class="language-{html.escape(lang)}"' if lang else ""
|
||||
theme = html.escape(self._options.code_theme)
|
||||
return f'<pre class="code-theme-{theme}"><code{lang_cls}>{code}</code></pre>'
|
||||
|
||||
def _render_thematic_break(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return "<hr>"
|
||||
|
||||
def _render_mermaid(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
warnings.append(_MERMAID_WARNING)
|
||||
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
|
||||
|
||||
@staticmethod
|
||||
def _format_plot_diagnostic(diag) -> str:
|
||||
loc = f"(第 {diag.line} 行)" if diag.line else ""
|
||||
return f"函数图像:{diag.message}{loc}"
|
||||
|
||||
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
|
||||
self._plot_count += 1
|
||||
if self._plot_count > _MAX_FUNCTION_PLOTS:
|
||||
warnings.append(
|
||||
f"函数图像:文档内函数图像数量超过上限 {_MAX_FUNCTION_PLOTS},已回退为源码占位"
|
||||
)
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
|
||||
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
|
||||
try:
|
||||
request = StaticRenderRequest(
|
||||
kind="function_plot", source=node.text, theme=self._options.theme_id
|
||||
)
|
||||
parsed = self._plot_renderer.parse(request)
|
||||
for diag in parsed.diagnostics:
|
||||
warnings.append(self._format_plot_diagnostic(diag))
|
||||
if parsed.plot is None:
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
|
||||
if self._plot_nodes + parsed.plot.node_count > _MAX_TOTAL_PLOT_NODES:
|
||||
warnings.append(
|
||||
f"函数图像:文档内函数图像累计复杂度超过上限 {_MAX_TOTAL_PLOT_NODES} 节点,已回退为源码占位"
|
||||
)
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
self._plot_nodes += parsed.plot.node_count
|
||||
rendered = self._plot_renderer.render_plot(parsed.plot)
|
||||
except Exception as exc:
|
||||
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
warnings.extend(rendered.warnings)
|
||||
return f'<figure class="function-plot">{rendered.content}</figure>'
|
||||
|
||||
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
|
||||
|
||||
def _render_html_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
# 原始 HTML 不可信,转义为纯文本展示,保证正文不丢且无注入风险
|
||||
warnings.append(_RAW_HTML_WARNING)
|
||||
return f'<div class="raw-html">{html.escape(node.text)}</div>'
|
||||
|
||||
# --- 行内 ---
|
||||
def _render_text(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return html.escape(node.text)
|
||||
|
||||
def _render_emphasis(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<em>{self._render_children(node.children, warnings)}</em>"
|
||||
|
||||
def _render_strong(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<strong>{self._render_children(node.children, warnings)}</strong>"
|
||||
|
||||
def _render_link(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
inner = self._render_children(node.children, warnings)
|
||||
href = str(node.attributes.get("href") or "")
|
||||
safe_href = _safe_url(href)
|
||||
if safe_href is None:
|
||||
# 危险协议(如 javascript:)降级为纯文本,不输出可点击链接
|
||||
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
|
||||
return inner
|
||||
title = str(node.attributes.get("title") or "")
|
||||
attrs = [f'href="{html.escape(safe_href)}"']
|
||||
if title:
|
||||
attrs.append(f'title="{html.escape(title)}"')
|
||||
return f"<a {' '.join(attrs)}>{inner}</a>"
|
||||
|
||||
def _render_codespan(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"<code>{html.escape(node.text)}</code>"
|
||||
|
||||
def _render_image(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
src = str(node.attributes.get("src") or "")
|
||||
alt = str(node.attributes.get("alt") or "")
|
||||
safe_src = _safe_url(src)
|
||||
if safe_src is None:
|
||||
# 危险协议(如 data:/javascript:)跳过图片,仅输出 alt 文本
|
||||
warnings.append(f"图片地址不安全,已跳过:{src!r}")
|
||||
return html.escape(alt) if alt else ""
|
||||
title = str(node.attributes.get("title") or "")
|
||||
attrs = [f'src="{html.escape(safe_src)}"', f'alt="{html.escape(alt)}"']
|
||||
if title:
|
||||
attrs.append(f'title="{html.escape(title)}"')
|
||||
return f"<img {' '.join(attrs)}>"
|
||||
|
||||
def _render_math_inline(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return f"\\({html.escape(node.text)}\\)"
|
||||
|
||||
def _render_linebreak(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
return "<br>"
|
||||
@@ -0,0 +1,303 @@
|
||||
"""PdfExporter:Document AST → PDF(reportlab platypus)。
|
||||
|
||||
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
||||
function_plot 与 mermaid 保留源码占位并记 warning。中文字体用 reportlab 内置
|
||||
STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立 bold/italic 字重,
|
||||
故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
from io import BytesIO
|
||||
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import A4, letter
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
from reportlab.platypus import (
|
||||
Paragraph,
|
||||
Preformatted,
|
||||
SimpleDocTemplate,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
)
|
||||
from reportlab.platypus.flowables import HRFlowable
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.document import Document, DocumentNode, ExportResult
|
||||
from app.export.exporters._common import (
|
||||
MERMAID_WARNING,
|
||||
PLOT_PLACEHOLDER_WARNING,
|
||||
RAW_HTML_WARNING,
|
||||
format_meta_value,
|
||||
safe_url,
|
||||
)
|
||||
|
||||
_FONT = "STSong-Light"
|
||||
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
|
||||
|
||||
_MIME = "application/pdf"
|
||||
|
||||
_PAGE_SIZES = {"a4": A4, "letter": letter}
|
||||
|
||||
# 标题字号随层级递减;标题不依赖粗体(CID 无粗体字重),靠字号拉开层级
|
||||
_HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
|
||||
|
||||
|
||||
def _make_styles() -> dict[str, ParagraphStyle]:
|
||||
body = ParagraphStyle(
|
||||
"pdf-body",
|
||||
fontName=_FONT,
|
||||
fontSize=10.5,
|
||||
leading=16,
|
||||
spaceAfter=6,
|
||||
)
|
||||
title = ParagraphStyle("pdf-title", parent=body, fontSize=22, leading=28, spaceAfter=12)
|
||||
quote = ParagraphStyle(
|
||||
"pdf-quote",
|
||||
parent=body,
|
||||
leftIndent=14,
|
||||
textColor="#57606a",
|
||||
spaceBefore=4,
|
||||
spaceAfter=6,
|
||||
)
|
||||
code = ParagraphStyle(
|
||||
"pdf-code",
|
||||
parent=body,
|
||||
fontSize=9,
|
||||
leading=12,
|
||||
leftIndent=6,
|
||||
rightIndent=6,
|
||||
backColor="#f6f8fa",
|
||||
borderColor="#d0d7de",
|
||||
borderWidth=0.5,
|
||||
borderPadding=6,
|
||||
spaceBefore=4,
|
||||
spaceAfter=8,
|
||||
)
|
||||
math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6)
|
||||
cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0)
|
||||
cell_head = ParagraphStyle(
|
||||
"pdf-cell-head", parent=cell, textColor="#1f2328", fontSize=10
|
||||
)
|
||||
meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor="#57606a")
|
||||
styles: dict[str, ParagraphStyle] = {
|
||||
"body": body,
|
||||
"title": title,
|
||||
"quote": quote,
|
||||
"code": code,
|
||||
"math": math,
|
||||
"cell": cell,
|
||||
"cell_head": cell_head,
|
||||
"meta": meta,
|
||||
}
|
||||
for level, size in _HEADING_SIZES.items():
|
||||
styles[f"h{level}"] = ParagraphStyle(
|
||||
f"pdf-h{level}",
|
||||
parent=body,
|
||||
fontSize=size,
|
||||
leading=size * 1.4,
|
||||
spaceBefore=14 if level <= 2 else 10,
|
||||
spaceAfter=6,
|
||||
)
|
||||
return styles
|
||||
|
||||
|
||||
class PdfExporter:
|
||||
"""实现 DocumentExporter:递归渲染 Document AST 为 PDF 字节流。"""
|
||||
|
||||
def render(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
|
||||
self._styles = _make_styles()
|
||||
warnings: list[str] = []
|
||||
|
||||
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
|
||||
buf = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=page,
|
||||
leftMargin=20 * mm,
|
||||
rightMargin=20 * mm,
|
||||
topMargin=18 * mm,
|
||||
bottomMargin=18 * mm,
|
||||
title=str(document.attributes.get("title") or "") or None,
|
||||
)
|
||||
|
||||
story: list = []
|
||||
self._render_header(document, options, story)
|
||||
self._render_children(document.children, story, warnings)
|
||||
|
||||
doc.build(story)
|
||||
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
|
||||
|
||||
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
|
||||
"""契约要求的 async 接口;渲染本身同步,直接转发到 render。"""
|
||||
return self.render(document, options)
|
||||
|
||||
# --- 文档头部 ---
|
||||
def _render_header(self, document: Document, options: ExportOptions, story: list) -> None:
|
||||
title = str(document.attributes.get("title") or "")
|
||||
if options.include_title and title:
|
||||
story.append(Paragraph(_html.escape(title), self._styles["title"]))
|
||||
if options.include_metadata:
|
||||
metadata = document.attributes.get("metadata")
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
text = f"{_html.escape(str(key))}: {_html.escape(format_meta_value(value))}"
|
||||
story.append(Paragraph(text, self._styles["meta"]))
|
||||
|
||||
# --- 块级 ---
|
||||
def _render_children(self, children: list[DocumentNode], story: list, warnings: list[str]) -> None:
|
||||
for child in children:
|
||||
self._render_block(child, story, warnings)
|
||||
|
||||
def _render_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
handler = getattr(self, f"_block_{node.type}", None)
|
||||
if handler is not None:
|
||||
handler(node, story, warnings)
|
||||
else:
|
||||
warnings.append(f"无法表示的节点类型已跳过:{node.type}")
|
||||
|
||||
def _block_heading(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
level = max(1, min(6, int(node.attributes.get("level", 1))))
|
||||
inline = self._render_inline(node.children, warnings)
|
||||
story.append(Paragraph(inline, self._styles[f"h{level}"]))
|
||||
|
||||
def _block_paragraph(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["body"]))
|
||||
|
||||
def _block_blockquote(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
story.append(Paragraph(self._render_inline(node.children, warnings), self._styles["quote"]))
|
||||
|
||||
def _block_list(self, node: DocumentNode, story: list, warnings: list[str], indent: int = 14) -> None:
|
||||
ordered = bool(node.attributes.get("ordered"))
|
||||
for index, item in enumerate(node.children, start=1):
|
||||
self._block_list_item(item, story, warnings, ordered, index, indent)
|
||||
|
||||
def _block_list_item(
|
||||
self,
|
||||
item: DocumentNode,
|
||||
story: list,
|
||||
warnings: list[str],
|
||||
ordered: bool,
|
||||
index: int,
|
||||
indent: int,
|
||||
) -> None:
|
||||
if item.attributes.get("task"):
|
||||
marker = "☑ " if item.attributes.get("checked") else "☐ "
|
||||
else:
|
||||
marker = f"{index}. " if ordered else "• "
|
||||
style = ParagraphStyle(
|
||||
f"pdf-li-{indent}",
|
||||
parent=self._styles["body"],
|
||||
leftIndent=indent,
|
||||
firstLineIndent=-7,
|
||||
spaceAfter=2,
|
||||
)
|
||||
# 列表项内容通常是单个段落或直接行内节点,嵌套列表单独递归加深缩进
|
||||
parts: list[str] = []
|
||||
for child in item.children:
|
||||
if child.type == "list":
|
||||
self._block_list(child, story, warnings, indent + 14)
|
||||
elif child.type == "paragraph":
|
||||
parts.append(self._render_inline(child.children, warnings))
|
||||
elif child.children:
|
||||
parts.append(self._render_inline(child.children, warnings))
|
||||
else:
|
||||
parts.append(_html.escape(child.text))
|
||||
story.append(Paragraph(marker + "<br/>".join(parts), style))
|
||||
|
||||
def _block_table(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
rows = node.children
|
||||
if not rows:
|
||||
return
|
||||
data: list[list[Paragraph]] = []
|
||||
head_row_count = 0
|
||||
for row in rows:
|
||||
head = bool(row.attributes.get("head"))
|
||||
if head:
|
||||
head_row_count += 1
|
||||
cells = [
|
||||
Paragraph(
|
||||
self._render_inline(cell.children, warnings),
|
||||
self._styles["cell_head" if cell.attributes.get("head") else "cell"],
|
||||
)
|
||||
for cell in row.children
|
||||
]
|
||||
data.append(cells)
|
||||
table = Table(data, repeatRows=head_row_count)
|
||||
commands = [
|
||||
("GRID", (0, 0), (-1, -1), 0.5, "#d0d7de"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
]
|
||||
if head_row_count:
|
||||
commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), "#f6f8fa"))
|
||||
table.setStyle(TableStyle(commands))
|
||||
story.append(table)
|
||||
|
||||
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
story.append(Preformatted(node.text, self._styles["code"]))
|
||||
|
||||
def _block_thematic_break(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(HRFlowable(width="100%", color="#d0d7de", thickness=0.5))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
warnings.append(MERMAID_WARNING)
|
||||
story.append(Preformatted(node.text, self._styles["code"]))
|
||||
|
||||
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
warnings.append(PLOT_PLACEHOLDER_WARNING)
|
||||
story.append(Preformatted(node.text, self._styles["code"]))
|
||||
|
||||
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
|
||||
|
||||
def _block_html_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
|
||||
# 原始 HTML 不可信,按纯文本保留正文
|
||||
warnings.append(RAW_HTML_WARNING)
|
||||
story.append(Paragraph(_html.escape(node.text), self._styles["body"]))
|
||||
|
||||
# --- 行内(产出 reportlab Paragraph 标记文本) ---
|
||||
def _render_inline(self, children: list[DocumentNode], warnings: list[str]) -> str:
|
||||
return "".join(self._render_inline_node(child, warnings) for child in children)
|
||||
|
||||
def _render_inline_node(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||
t = node.type
|
||||
if t == "text":
|
||||
return _html.escape(node.text)
|
||||
if t in ("strong", "emphasis"):
|
||||
return self._render_inline(node.children, warnings)
|
||||
if t == "codespan":
|
||||
return f'<font size="9">{_html.escape(node.text)}</font>'
|
||||
if t == "link":
|
||||
inner = self._render_inline(node.children, warnings)
|
||||
href = str(node.attributes.get("href") or "")
|
||||
safe_href = safe_url(href)
|
||||
if safe_href is None:
|
||||
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
|
||||
return inner
|
||||
return f'<a href="{_html.escape(safe_href)}">{inner}</a>'
|
||||
if t == "image":
|
||||
src = str(node.attributes.get("src") or "")
|
||||
alt = str(node.attributes.get("alt") or "")
|
||||
if safe_url(src) is None:
|
||||
warnings.append(f"图片地址不安全,已跳过:{src!r}")
|
||||
else:
|
||||
warnings.append("图片未内嵌到 PDF,已用替代文本表示")
|
||||
return _html.escape(alt) if alt else ""
|
||||
if t == "math_inline":
|
||||
return f"\\({_html.escape(node.text)}\\)"
|
||||
if t == "linebreak":
|
||||
return "<br/>"
|
||||
warnings.append(f"无法表示的行内节点已跳过:{t}")
|
||||
return ""
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Markdown → Document AST:用 mistune 的 ast renderer 产出通用 token,再映射为内部节点。
|
||||
|
||||
选用 mistune 内置 'ast' renderer 而非自写 BaseRenderer,是因为 mistune 的行内渲染按
|
||||
字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 children/attrs/raw 的 token
|
||||
树,映射层只做 token → DocumentNode 的搬运,不掺入任何 HTML。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mistune
|
||||
|
||||
from app.export.document import Document, DocumentNode
|
||||
|
||||
_PLUGINS = ["table", "math", "url", "task_lists"]
|
||||
|
||||
# fenced code 语言分流:命中则转为专用节点,其余按普通代码块
|
||||
_MERMAID_LANG = "mermaid"
|
||||
_FUNCTION_PLOT_LANGS = {"function-plot", "function_plot", "functionplot"}
|
||||
|
||||
|
||||
def parse_document(markdown: str) -> Document:
|
||||
"""把 Markdown 文本解析为 Document AST 根节点。"""
|
||||
renderer = mistune.create_markdown(renderer="ast", plugins=_PLUGINS)
|
||||
tokens = renderer(markdown)
|
||||
mapper = _AstMapper()
|
||||
return Document(node_id=mapper.next_id(), children=mapper.map_blocks(tokens))
|
||||
|
||||
|
||||
class _AstMapper:
|
||||
"""token 树 → DocumentNode 树的映射器;node_id 按遍历顺序递增,无需跨请求稳定。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._seq = 0
|
||||
|
||||
def next_id(self) -> str:
|
||||
self._seq += 1
|
||||
return f"node_{self._seq:03d}"
|
||||
|
||||
def map_blocks(self, tokens: list[dict]) -> list[DocumentNode]:
|
||||
nodes: list[DocumentNode] = []
|
||||
for token in tokens:
|
||||
node = self.map_block(token)
|
||||
if node is not None:
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def map_block(self, token: dict) -> DocumentNode | None:
|
||||
kind = token["type"]
|
||||
if kind == "heading":
|
||||
return DocumentNode(
|
||||
type="heading",
|
||||
node_id=self.next_id(),
|
||||
attributes={"level": token["attrs"]["level"]},
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind in ("paragraph", "block_text"):
|
||||
# block_text 是列表项内的段落块,仍按 paragraph 表达,由 list_item 包裹
|
||||
return DocumentNode(
|
||||
type="paragraph",
|
||||
node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "list":
|
||||
return DocumentNode(
|
||||
type="list",
|
||||
node_id=self.next_id(),
|
||||
attributes={"ordered": bool(token.get("attrs", {}).get("ordered"))},
|
||||
children=[self.map_list_item(child) for child in token.get("children", [])],
|
||||
)
|
||||
if kind == "block_code":
|
||||
return self._map_code(token)
|
||||
if kind == "block_quote":
|
||||
return DocumentNode(
|
||||
type="blockquote",
|
||||
node_id=self.next_id(),
|
||||
children=self.map_blocks(token.get("children", [])),
|
||||
)
|
||||
if kind == "table":
|
||||
return self._map_table(token)
|
||||
if kind == "block_math":
|
||||
return DocumentNode(
|
||||
type="math_block", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
if kind == "thematic_break":
|
||||
return DocumentNode(type="thematic_break", node_id=self.next_id())
|
||||
if kind == "blank_line":
|
||||
return None
|
||||
if kind == "block_html":
|
||||
# 原始 HTML 块降级为纯文本节点,由 HtmlExporter 转义并记 warning,避免静默丢失正文
|
||||
return DocumentNode(
|
||||
type="html_block", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
# 未知块级 token 保守保留原文;映射为带 text 子节点的 paragraph,避免被渲染层丢弃
|
||||
raw = token.get("raw", "")
|
||||
if raw:
|
||||
return DocumentNode(
|
||||
type="paragraph",
|
||||
node_id=self.next_id(),
|
||||
children=[DocumentNode(type="text", node_id=self.next_id(), text=raw)],
|
||||
)
|
||||
return None
|
||||
|
||||
def map_list_item(self, token: dict) -> DocumentNode:
|
||||
"""列表项:block_text 展平为行内子节点,嵌套 list 保留为子节点。"""
|
||||
attributes: dict = {}
|
||||
if token["type"] == "task_list_item":
|
||||
attributes = {"task": True, "checked": bool(token.get("attrs", {}).get("checked"))}
|
||||
children: list[DocumentNode] = []
|
||||
for child in token.get("children", []):
|
||||
if child["type"] == "block_text":
|
||||
children.extend(self.map_inline(child.get("children", [])))
|
||||
elif child["type"] == "list":
|
||||
children.append(self.map_block(child))
|
||||
else:
|
||||
node = self.map_block(child)
|
||||
if node is not None:
|
||||
children.append(node)
|
||||
return DocumentNode(
|
||||
type="list_item", node_id=self.next_id(), attributes=attributes, children=children
|
||||
)
|
||||
|
||||
def map_inline(self, tokens: list[dict]) -> list[DocumentNode]:
|
||||
nodes: list[DocumentNode] = []
|
||||
for token in tokens:
|
||||
node = self.map_inline_token(token)
|
||||
if node is not None:
|
||||
nodes.append(node)
|
||||
return nodes
|
||||
|
||||
def map_inline_token(self, token: dict) -> DocumentNode | None:
|
||||
kind = token["type"]
|
||||
if kind == "text":
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""))
|
||||
if kind == "strong":
|
||||
return DocumentNode(
|
||||
type="strong", node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "emphasis":
|
||||
return DocumentNode(
|
||||
type="emphasis", node_id=self.next_id(),
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "link":
|
||||
attrs = token.get("attrs", {})
|
||||
attributes = {"href": attrs.get("url", "")}
|
||||
if attrs.get("title"):
|
||||
attributes["title"] = attrs["title"]
|
||||
return DocumentNode(
|
||||
type="link", node_id=self.next_id(), attributes=attributes,
|
||||
children=self.map_inline(token.get("children", [])),
|
||||
)
|
||||
if kind == "codespan":
|
||||
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
|
||||
if kind == "image":
|
||||
# mistune 图片 token:src 在 attrs.url,alt 来自 children 的文本,title 在 attrs.title
|
||||
attrs = token.get("attrs", {})
|
||||
alt = "".join(
|
||||
child.get("raw", "")
|
||||
for child in token.get("children", [])
|
||||
if child.get("type") == "text"
|
||||
)
|
||||
attributes = {"src": attrs.get("url", "")}
|
||||
if alt:
|
||||
attributes["alt"] = alt
|
||||
if attrs.get("title"):
|
||||
attributes["title"] = attrs["title"]
|
||||
return DocumentNode(type="image", node_id=self.next_id(), attributes=attributes)
|
||||
if kind == "inline_math":
|
||||
return DocumentNode(
|
||||
type="math_inline", node_id=self.next_id(), text=token.get("raw", "")
|
||||
)
|
||||
if kind == "softbreak":
|
||||
# HTML 中换行会折叠为空白,软换行按空格表达
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=" ")
|
||||
if kind == "linebreak":
|
||||
return DocumentNode(type="linebreak", node_id=self.next_id())
|
||||
# 未知行内 token 保守保留原文
|
||||
raw = token.get("raw", "")
|
||||
if raw:
|
||||
return DocumentNode(type="text", node_id=self.next_id(), text=raw)
|
||||
return None
|
||||
|
||||
def _map_code(self, token: dict) -> DocumentNode:
|
||||
info = (token.get("attrs", {}).get("info") or "").strip()
|
||||
lang = info.split()[0].lower() if info else ""
|
||||
code = token.get("raw", "").rstrip("\n")
|
||||
if lang == _MERMAID_LANG:
|
||||
return DocumentNode(type="mermaid", node_id=self.next_id(), text=code)
|
||||
if lang in _FUNCTION_PLOT_LANGS:
|
||||
return DocumentNode(type="function_plot", node_id=self.next_id(), text=code)
|
||||
attributes = {"language": lang} if lang else {}
|
||||
return DocumentNode(
|
||||
type="code_block", node_id=self.next_id(), attributes=attributes, text=code
|
||||
)
|
||||
|
||||
def _map_table(self, token: dict) -> DocumentNode:
|
||||
rows: list[DocumentNode] = []
|
||||
for child in token.get("children", []):
|
||||
if child["type"] == "table_head":
|
||||
rows.append(self._map_table_row(child, head=True))
|
||||
elif child["type"] == "table_body":
|
||||
for row in child.get("children", []):
|
||||
if row["type"] == "table_row":
|
||||
rows.append(self._map_table_row(row, head=False))
|
||||
elif child["type"] == "table_row":
|
||||
rows.append(self._map_table_row(child, head=False))
|
||||
return DocumentNode(type="table", node_id=self.next_id(), children=rows)
|
||||
|
||||
def _map_table_row(self, token: dict, *, head: bool) -> DocumentNode:
|
||||
cells: list[DocumentNode] = []
|
||||
for cell in token.get("children", []):
|
||||
if cell["type"] != "table_cell":
|
||||
continue
|
||||
attrs = cell.get("attrs", {})
|
||||
cell_attributes = {"head": bool(attrs.get("head", head))}
|
||||
if attrs.get("align"):
|
||||
cell_attributes["align"] = attrs["align"]
|
||||
cells.append(
|
||||
DocumentNode(
|
||||
type="table_cell",
|
||||
node_id=self.next_id(),
|
||||
attributes=cell_attributes,
|
||||
children=self.map_inline(cell.get("children", [])),
|
||||
)
|
||||
)
|
||||
return DocumentNode(
|
||||
type="table_row", node_id=self.next_id(), attributes={"head": head}, children=cells
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Export 服务:任务注册表、后台渲染、取消与产物生命周期。
|
||||
|
||||
与 Benchmark 一致采用「创建即返回 queued、后台 Task 异步执行」的内存模型:任务与产物
|
||||
暂存内存与 exports 目录,不持久化到 SQLite。导出是单阶段渲染,无 SSE 事件流,取消主要
|
||||
在渲染前/后让出执行权的边界生效;产物带 24h 过期时间,过期后不可下载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
ExportFile,
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportOptions,
|
||||
ExportProgress,
|
||||
ExportRequest,
|
||||
ExportSource,
|
||||
ExportSourceType,
|
||||
ExportStatus,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
from app.export.document import Document, ExportResult
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
from app.export.markdown import parse_document
|
||||
from app.services import note_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_jobs: dict[str, ExportJob] = {}
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_cancel_flags: dict[str, asyncio.Event] = {}
|
||||
MAX_JOBS = 100
|
||||
# 输入源(note / markdown)统一大小上限,防止未保存预览或超长笔记塞爆内存/产物
|
||||
MAX_MARKDOWN_CHARS = 200_000
|
||||
# 最终导出产物大小上限,防止超大 HTML 耗尽内存/磁盘
|
||||
MAX_EXPORT_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
# 并发渲染上限:解析/渲染是 CPU 密集的同步工作,限制同时执行的任务数,
|
||||
# 防止大量任务同时占满工作线程与内存
|
||||
MAX_CONCURRENT_RENDERS = 2
|
||||
_render_slots = asyncio.Semaphore(MAX_CONCURRENT_RENDERS)
|
||||
# 产物有效期
|
||||
FILE_TTL = timedelta(hours=24)
|
||||
|
||||
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
# 格式 → 导出器;新增格式只需在此登记,路由与任务模型无需改动
|
||||
_EXPORTERS: dict[ExportFormat, type] = {
|
||||
ExportFormat.html: HtmlExporter,
|
||||
ExportFormat.pdf: PdfExporter,
|
||||
ExportFormat.docx: DocxExporter,
|
||||
}
|
||||
|
||||
# 格式 → 文件扩展名(用于落盘文件名与产物清理)
|
||||
_EXTENSIONS: dict[ExportFormat, str] = {
|
||||
ExportFormat.html: ".html",
|
||||
ExportFormat.pdf: ".pdf",
|
||||
ExportFormat.docx: ".docx",
|
||||
}
|
||||
|
||||
|
||||
def _extension_for(format: ExportFormat) -> str:
|
||||
return _EXTENSIONS[format]
|
||||
|
||||
|
||||
class ExportCancelled(Exception):
|
||||
"""导出在渲染前被取消时抛出,用于标记 cancelled。"""
|
||||
|
||||
|
||||
class ExportTooLarge(Exception):
|
||||
"""导出产物超过大小上限时抛出,用于标记 failed 并携带专用错误码。"""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _safe_download_name(title: str) -> str:
|
||||
"""清洗标题得到安全的下载文件名;空标题回退到 export。"""
|
||||
name = _INVALID_FILE_CHARS.sub("_", title).strip() or "export"
|
||||
return name[:80]
|
||||
|
||||
|
||||
def _export_path(job_id: str, ext: str) -> Path:
|
||||
return get_settings().exports_path / f"{job_id}{ext}"
|
||||
|
||||
|
||||
def _delete_file(job_id: str, ext: str) -> None:
|
||||
"""删除导出产物文件;文件不存在时忽略。"""
|
||||
try:
|
||||
_export_path(job_id, ext).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.warning("Failed to delete export file: %s", job_id)
|
||||
|
||||
|
||||
def cleanup_orphan_files() -> int:
|
||||
"""清理 exports 目录下无对应内存任务的孤立产物(服务重启后调用)。"""
|
||||
exports_dir = get_settings().exports_path
|
||||
if not exports_dir.is_dir():
|
||||
return 0
|
||||
removed = 0
|
||||
for ext in _EXTENSIONS.values():
|
||||
for path in exports_dir.glob(f"*{ext}"):
|
||||
if path.stem not in _jobs:
|
||||
try:
|
||||
path.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
logger.warning("Failed to delete orphan export file: %s", path)
|
||||
return removed
|
||||
|
||||
|
||||
def _render_document(document: Document, options: ExportOptions, format: ExportFormat) -> ExportResult:
|
||||
"""按 format 分发到对应导出器;每次新建实例避免跨线程复用。"""
|
||||
exporter_cls = _EXPORTERS[format]
|
||||
return exporter_cls().render(document, options)
|
||||
|
||||
|
||||
def _forget(job_id: str) -> None:
|
||||
job = _jobs.get(job_id)
|
||||
ext = _extension_for(job.format) if job is not None else ".html"
|
||||
_jobs.pop(job_id, None)
|
||||
_tasks.pop(job_id, None)
|
||||
_cancel_flags.pop(job_id, None)
|
||||
_delete_file(job_id, ext)
|
||||
|
||||
|
||||
def _evict_terminal() -> bool:
|
||||
"""超过容量时淘汰最旧的终态任务;全为活动任务无法淘汰时返回 False。"""
|
||||
terminal = (ExportStatus.completed, ExportStatus.failed, ExportStatus.cancelled)
|
||||
while len(_jobs) >= MAX_JOBS:
|
||||
victim = next((jid for jid, job in _jobs.items() if job.status in terminal), None)
|
||||
if victim is None:
|
||||
return False
|
||||
_forget(victim)
|
||||
return True
|
||||
|
||||
|
||||
async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
|
||||
"""把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。"""
|
||||
if source.type == ExportSourceType.note:
|
||||
note = await note_service.get_note(source.note_id)
|
||||
if note is None:
|
||||
raise ApiError(
|
||||
404,
|
||||
"EXPORT_SOURCE_NOT_FOUND",
|
||||
"note not found",
|
||||
{"note_id": source.note_id},
|
||||
)
|
||||
if len(note.markdown) > MAX_MARKDOWN_CHARS:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_OPTIONS_INVALID",
|
||||
f"note source exceeds {MAX_MARKDOWN_CHARS} characters",
|
||||
{"size": len(note.markdown), "limit": MAX_MARKDOWN_CHARS},
|
||||
)
|
||||
metadata = {
|
||||
"file_path": note.file_path,
|
||||
"tags": note.tags,
|
||||
"created_at": note.created_at,
|
||||
"updated_at": note.updated_at,
|
||||
}
|
||||
return note.markdown, note.title, metadata
|
||||
|
||||
markdown = source.markdown or ""
|
||||
if not markdown.strip():
|
||||
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
|
||||
if len(markdown) > MAX_MARKDOWN_CHARS:
|
||||
raise ApiError(
|
||||
400,
|
||||
"EXPORT_OPTIONS_INVALID",
|
||||
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
|
||||
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
|
||||
)
|
||||
return markdown, "", None
|
||||
|
||||
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
|
||||
markdown, title, metadata = await _resolve_source(request.source)
|
||||
|
||||
if not _evict_terminal():
|
||||
raise ApiError(
|
||||
429,
|
||||
"EXPORT_CAPACITY_EXCEEDED",
|
||||
"Export capacity exceeded; wait for active jobs to finish.",
|
||||
{},
|
||||
)
|
||||
|
||||
job_id = "export_" + uuid4().hex[:12]
|
||||
job = ExportJob(
|
||||
job_id=job_id,
|
||||
status=ExportStatus.queued,
|
||||
format=request.format,
|
||||
created_at=_now(),
|
||||
)
|
||||
_jobs[job_id] = job
|
||||
_cancel_flags[job_id] = asyncio.Event()
|
||||
_tasks[job_id] = asyncio.create_task(
|
||||
_execute(job_id, request.format, markdown, title, metadata, request.options)
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
async def _execute(
|
||||
job_id: str,
|
||||
format: ExportFormat,
|
||||
markdown: str,
|
||||
title: str,
|
||||
metadata: dict | None,
|
||||
options: ExportOptions,
|
||||
) -> None:
|
||||
"""后台渲染:解析 → 导出 → 写文件 → 挂载产物元信息。"""
|
||||
cancel_event = _cancel_flags[job_id]
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.running,
|
||||
"started_at": _now(),
|
||||
"progress": ExportProgress(phase="rendering", current=0, total=1, percent=0.0),
|
||||
}
|
||||
)
|
||||
try:
|
||||
# 并发渲染限额:解析/渲染是 CPU 密集的同步工作,用信号量限制同时执行的任务数,
|
||||
# 超出限额的任务在此排队等待,避免大量任务同时占满工作线程与内存
|
||||
async with _render_slots:
|
||||
# 让出一次,使「创建后立即取消」的 queued 任务能及时进入 cancelled
|
||||
await asyncio.sleep(0)
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
|
||||
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
|
||||
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
|
||||
document = await asyncio.to_thread(parse_document, markdown)
|
||||
document.attributes["title"] = title
|
||||
if metadata:
|
||||
document.attributes["metadata"] = metadata
|
||||
|
||||
result = await asyncio.to_thread(_render_document, document, options, format)
|
||||
if cancel_event.is_set():
|
||||
raise ExportCancelled()
|
||||
if len(result.content) > MAX_EXPORT_BYTES:
|
||||
raise ExportTooLarge()
|
||||
|
||||
ext = _extension_for(format)
|
||||
out_dir = get_settings().exports_path
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = _export_path(job_id, ext)
|
||||
path.write_bytes(result.content)
|
||||
|
||||
completed_at = _now()
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.completed,
|
||||
"progress": ExportProgress(
|
||||
phase="completed", current=1, total=1, percent=1.0
|
||||
),
|
||||
"file": ExportFile(
|
||||
file_name=f"{_safe_download_name(title)}{ext}",
|
||||
mime_type=result.mime_type,
|
||||
size=len(result.content),
|
||||
sha256=hashlib.sha256(result.content).hexdigest(),
|
||||
expires_at=completed_at + FILE_TTL,
|
||||
),
|
||||
"warnings": result.warnings,
|
||||
"completed_at": completed_at,
|
||||
}
|
||||
)
|
||||
except ExportCancelled:
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.cancelled,
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except ExportTooLarge:
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.failed,
|
||||
"error": "Export output exceeds size limit.",
|
||||
"error_code": "EXPORT_OUTPUT_TOO_LARGE",
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # 渲染失败不拖垮服务,只记日志与项目错误码
|
||||
logger.exception("Export failed: job_id=%s", job_id)
|
||||
_jobs[job_id] = _jobs[job_id].model_copy(
|
||||
update={
|
||||
"status": ExportStatus.failed,
|
||||
"error": "Export render failed.",
|
||||
"error_code": "EXPORT_RENDER_FAILED",
|
||||
"completed_at": _now(),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
_cancel_flags.pop(job_id, None)
|
||||
|
||||
|
||||
def list_exports(
|
||||
status: ExportStatus | None = None,
|
||||
format: ExportFormat | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[ExportJob], int]:
|
||||
jobs = list(_jobs.values())
|
||||
if status is not None:
|
||||
jobs = [j for j in jobs if j.status == status]
|
||||
if format is not None:
|
||||
jobs = [j for j in jobs if j.format == format]
|
||||
jobs.sort(key=lambda j: j.created_at, reverse=True)
|
||||
total = len(jobs)
|
||||
return jobs[offset : offset + limit], total
|
||||
|
||||
|
||||
def get_export(job_id: str) -> ExportJob | None:
|
||||
return _jobs.get(job_id)
|
||||
|
||||
|
||||
def cancel_export(job_id: str) -> ExportJob | None:
|
||||
"""取消导出:仅 queued/running 可取消,后台 Task 在让出边界标记 cancelled。"""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in (ExportStatus.queued, ExportStatus.running):
|
||||
_cancel_flags[job_id].set()
|
||||
return job
|
||||
|
||||
|
||||
def get_export_file(job_id: str) -> Path:
|
||||
"""返回可下载产物的存储路径;未完成返回 404、过期返回 410。"""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
raise ApiError(404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id})
|
||||
if job.status != ExportStatus.completed or job.file is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
|
||||
)
|
||||
if job.file.expires_at <= _now():
|
||||
_forget(job_id) # 过期即清理内存记录与产物文件
|
||||
raise ApiError(410, "EXPORT_FILE_EXPIRED", "export file has expired", {"job_id": job_id})
|
||||
return _export_path(job_id, _extension_for(job.format))
|
||||
|
||||
|
||||
async def wait_for_export(job_id: str) -> ExportJob | None:
|
||||
"""等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。"""
|
||||
task = _tasks.get(job_id)
|
||||
if task is not None:
|
||||
await task
|
||||
return _jobs.get(job_id)
|
||||
@@ -11,6 +11,7 @@ from starlette.exceptions import HTTPException as StarletteHttpException
|
||||
from app.config import get_settings
|
||||
from app.container import container
|
||||
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
|
||||
from app.export import service as export_service
|
||||
from app.routes import router as api_router
|
||||
from app.media_routes import router as media_router
|
||||
from app.local_model_routes import router as local_model_router
|
||||
@@ -27,6 +28,8 @@ settings = get_settings()
|
||||
async def lifespan(_: FastAPI):
|
||||
install_logging()
|
||||
log_event('system', 'service.started')
|
||||
# 重启后内存注册表为空,清理上一次运行遗留的导出产物,避免磁盘垃圾堆积。
|
||||
export_service.cleanup_orphan_files()
|
||||
from app.services import transcription_service
|
||||
transcription_service.recover_interrupted()
|
||||
try:
|
||||
@@ -41,6 +44,7 @@ async def lifespan(_: FastAPI):
|
||||
from app.local_models import manager
|
||||
for _, key in list(manager._downloads):
|
||||
await manager.cancel_download(key)
|
||||
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
|
||||
container.plugins.shutdown()
|
||||
container.mcp_servers.shutdown()
|
||||
log_event('system', 'service.stopped')
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Function Plot:函数图像的白名单表达式解析与静态 SVG 渲染。
|
||||
|
||||
模块划分:
|
||||
- model.py FunctionPlot 等内部数据模型(不进 contracts.py,同 Document AST)
|
||||
- parser.py function-plot 源码与表达式解析(ast 白名单,绝不 eval/exec)
|
||||
- render.py 把 FunctionPlot 渲染为内嵌 SVG(纯几何 + <text>,无脚本)
|
||||
"""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Function Plot 内部数据模型。
|
||||
|
||||
契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部
|
||||
流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FunctionPlotExpression(BaseModel):
|
||||
"""单条函数表达式;expression 为数学表达式文本(不含 ``y =`` 前缀)。"""
|
||||
|
||||
expression: str
|
||||
label: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class PlotAxes(BaseModel):
|
||||
xlabel: str | None = None
|
||||
ylabel: str | None = None
|
||||
grid: bool = True
|
||||
|
||||
|
||||
class FunctionPlot(BaseModel):
|
||||
version: int = 1
|
||||
expressions: list[FunctionPlotExpression]
|
||||
domain: tuple[float, float] = (-10.0, 10.0)
|
||||
range: tuple[float, float] | None = None
|
||||
axes: PlotAxes = Field(default_factory=PlotAxes)
|
||||
# 该块所有表达式 AST 节点数之和,供导出器做文档级累计复杂度预算
|
||||
node_count: int = 0
|
||||
|
||||
|
||||
class PlotDiagnostic(BaseModel):
|
||||
severity: Literal["warning", "error"]
|
||||
code: str
|
||||
message: str
|
||||
line: int | None = None
|
||||
|
||||
|
||||
class FunctionPlotParseResult(BaseModel):
|
||||
"""解析结果:任一表达式 error 时 plot 为 None(整块回退占位),仅 warning 时 plot 有效。"""
|
||||
|
||||
plot: FunctionPlot | None = None
|
||||
diagnostics: list[PlotDiagnostic] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StaticRenderResult(BaseModel):
|
||||
content: str
|
||||
mime_type: str = "image/svg+xml"
|
||||
width: int
|
||||
height: int
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Function Plot 表达式解析:白名单数学语法,绝不执行 eval / 函数构造器 / 属性访问。
|
||||
|
||||
安全模型:先用 ``ast.parse(mode='eval')`` 把表达式变成纯 AST(这一步不执行任何代码),
|
||||
再逐节点白名单校验(只允许数字、变量 ``x``、常量 ``pi/e``、白名单函数调用与四则/幂
|
||||
运算),最后用递归解释器直接计算数值——全程不 ``compile``/``exec`` 字符串。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
import re
|
||||
from typing import NoReturn
|
||||
|
||||
from app.plot.model import (
|
||||
FunctionPlot,
|
||||
FunctionPlotExpression,
|
||||
FunctionPlotParseResult,
|
||||
PlotAxes,
|
||||
PlotDiagnostic,
|
||||
)
|
||||
|
||||
# 白名单函数(ln 是 log 的别名);abs 用内置函数,其余映射到 math
|
||||
_FUNCTION_IMPL: dict[str, object] = {
|
||||
"sin": math.sin,
|
||||
"cos": math.cos,
|
||||
"tan": math.tan,
|
||||
"asin": math.asin,
|
||||
"acos": math.acos,
|
||||
"atan": math.atan,
|
||||
"sinh": math.sinh,
|
||||
"cosh": math.cosh,
|
||||
"tanh": math.tanh,
|
||||
"exp": math.exp,
|
||||
"log": math.log,
|
||||
"ln": math.log,
|
||||
"log10": math.log10,
|
||||
"log2": math.log2,
|
||||
"sqrt": math.sqrt,
|
||||
"abs": abs,
|
||||
}
|
||||
_FUNCTIONS = frozenset(_FUNCTION_IMPL)
|
||||
_CONSTANTS: dict[str, float] = {"pi": math.pi, "e": math.e}
|
||||
|
||||
_ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow)
|
||||
_ALLOWED_UNARY = (ast.UAdd, ast.USub)
|
||||
_DIRECTIVE_KEYS = frozenset({"domain", "range", "xlabel", "ylabel", "grid"})
|
||||
_NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
|
||||
|
||||
# 表达式复杂度上限:深层嵌套或海量节点在递归校验/求值时会触发 RecursionError,
|
||||
# 用白名单校验提前拦截,保证失败走正常诊断路径而不是异常逃逸出导出链路。
|
||||
_MAX_AST_DEPTH = 200
|
||||
_MAX_AST_NODES = 1000
|
||||
# 单块 function-plot 允许的表达式数量上限,防止海量表达式导致超大 SVG 与海量采样求值
|
||||
_MAX_EXPRESSIONS = 16
|
||||
|
||||
|
||||
class PlotParseError(Exception):
|
||||
"""表达式解析/校验失败,携带可定位诊断。"""
|
||||
|
||||
def __init__(self, diagnostic: PlotDiagnostic) -> None:
|
||||
super().__init__(diagnostic.message)
|
||||
self.diagnostic = diagnostic
|
||||
|
||||
|
||||
def _unsafe(message: str) -> NoReturn:
|
||||
raise PlotParseError(
|
||||
PlotDiagnostic(severity="error", code="FUNCTION_PLOT_EXPRESSION_UNSAFE", message=message)
|
||||
)
|
||||
|
||||
|
||||
def _is_number(tok: str) -> bool:
|
||||
return bool(_NUMBER_RE.match(tok))
|
||||
|
||||
|
||||
def _tokenize(s: str) -> list[str]:
|
||||
"""把预处理后的表达式切成数字/标识符/运算符/括号 token。"""
|
||||
tokens: list[str] = []
|
||||
i = 0
|
||||
n = len(s)
|
||||
while i < n:
|
||||
ch = s[i]
|
||||
if ch.isspace():
|
||||
i += 1
|
||||
continue
|
||||
if ch.isdigit() or ch == ".":
|
||||
j = i
|
||||
while j < n and (s[j].isdigit() or s[j] == "."):
|
||||
j += 1
|
||||
# 科学计数法:数字后紧跟 e/E[+-]数字 视为同一数字
|
||||
if j < n and s[j] in "eE":
|
||||
k = j + 1
|
||||
if k < n and s[k] in "+-":
|
||||
k += 1
|
||||
if k < n and s[k].isdigit():
|
||||
while k < n and s[k].isdigit():
|
||||
k += 1
|
||||
j = k
|
||||
tokens.append(s[i:j])
|
||||
i = j
|
||||
continue
|
||||
if ch.isalpha() or ch == "_":
|
||||
j = i
|
||||
while j < n and (s[j].isalnum() or s[j] == "_"):
|
||||
j += 1
|
||||
tokens.append(s[i:j])
|
||||
i = j
|
||||
continue
|
||||
if ch == "*" and i + 1 < n and s[i + 1] == "*":
|
||||
tokens.append("**")
|
||||
i += 2
|
||||
continue
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_value_end(tok: str) -> bool:
|
||||
"""该 token 之后允许补乘号(数字/右括号/变量 x/常量)。"""
|
||||
return tok == ")" or _is_number(tok) or tok == "x" or tok in _CONSTANTS
|
||||
|
||||
|
||||
def _is_value_start(tok: str) -> bool:
|
||||
"""该 token 可作为乘号右侧起点(左括号/数字/任意标识符,含函数名)。"""
|
||||
return tok == "(" or _is_number(tok) or (tok and (tok[0].isalpha() or tok[0] == "_"))
|
||||
|
||||
|
||||
def _insert_implicit_multiplication(s: str) -> str:
|
||||
"""补隐式乘法:2x、2(x+1)、(x+1)(x-1)、x sin(x) 等;函数名后的 ``(`` 是调用不补。"""
|
||||
tokens = _tokenize(s)
|
||||
out: list[str] = []
|
||||
prev: str | None = None
|
||||
for tok in tokens:
|
||||
if prev is not None and _is_value_end(prev) and _is_value_start(tok):
|
||||
out.append("*")
|
||||
out.append(tok)
|
||||
prev = tok
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _preprocess(expr: str) -> str:
|
||||
"""``^`` 视为幂,补隐式乘法后再交给 ast.parse。"""
|
||||
return _insert_implicit_multiplication(expr.replace("^", "**"))
|
||||
|
||||
|
||||
def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) -> None:
|
||||
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
|
||||
|
||||
同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发
|
||||
RecursionError 而绕过解析失败路径。
|
||||
"""
|
||||
if counter is None:
|
||||
counter = [0]
|
||||
if depth > _MAX_AST_DEPTH:
|
||||
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
|
||||
counter[0] += 1
|
||||
if counter[0] > _MAX_AST_NODES:
|
||||
_unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})")
|
||||
if isinstance(node, ast.Constant):
|
||||
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
|
||||
_unsafe(f"不支持的常量 {node.value!r}")
|
||||
return
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id == "x" or node.id in _CONSTANTS:
|
||||
return
|
||||
_unsafe(f"未知标识符 {node.id!r}")
|
||||
if isinstance(node, ast.BinOp):
|
||||
if not isinstance(node.op, _ALLOWED_BINOPS):
|
||||
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
||||
_check_node(node.left, depth + 1, counter)
|
||||
_check_node(node.right, depth + 1, counter)
|
||||
return
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
if not isinstance(node.op, _ALLOWED_UNARY):
|
||||
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
|
||||
_check_node(node.operand, depth + 1, counter)
|
||||
return
|
||||
if isinstance(node, ast.Call):
|
||||
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
|
||||
_unsafe(f"不支持的函数调用 {ast.dump(node.func)!r}")
|
||||
if node.keywords:
|
||||
_unsafe("函数调用不支持关键字参数")
|
||||
# 白名单内所有函数均恰取 1 个参数,提前校验避免求值期 TypeError
|
||||
if len(node.args) != 1:
|
||||
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个")
|
||||
for arg in node.args:
|
||||
_check_node(arg, depth + 1, counter)
|
||||
return
|
||||
_unsafe(f"不支持的语法 {type(node).__name__}")
|
||||
|
||||
|
||||
def parse_expression(expr: str) -> ast.Expression:
|
||||
"""把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。"""
|
||||
preprocessed = _preprocess(expr)
|
||||
try:
|
||||
tree = ast.parse(preprocessed, mode="eval")
|
||||
except SyntaxError as exc:
|
||||
raise PlotParseError(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"表达式语法错误:{exc.msg}",
|
||||
)
|
||||
) from exc
|
||||
except RecursionError as exc:
|
||||
# 极深嵌套可能在 ast.parse 阶段就触发 RecursionError,转为可定位诊断
|
||||
raise PlotParseError(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="表达式嵌套过深,无法解析",
|
||||
)
|
||||
) from exc
|
||||
_check_node(tree.body)
|
||||
return tree
|
||||
|
||||
|
||||
def _count_nodes(node: ast.AST) -> int:
|
||||
"""统计已通过校验的表达式 AST 节点数,供文档级累计复杂度预算使用。"""
|
||||
counter = [0]
|
||||
_check_node(node, counter=counter)
|
||||
return counter[0]
|
||||
|
||||
|
||||
def evaluate(expr_ast: ast.Expression, x: float) -> float:
|
||||
"""递归解释已校验 AST 得到数值,全程不编译/执行代码。"""
|
||||
return _eval_node(expr_ast.body, x)
|
||||
|
||||
|
||||
def _eval_node(node: ast.AST, x: float) -> float:
|
||||
if isinstance(node, ast.Constant):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.Name):
|
||||
return x if node.id == "x" else _CONSTANTS[node.id]
|
||||
if isinstance(node, ast.BinOp):
|
||||
left = _eval_node(node.left, x)
|
||||
right = _eval_node(node.right, x)
|
||||
if isinstance(node.op, ast.Add):
|
||||
return left + right
|
||||
if isinstance(node.op, ast.Sub):
|
||||
return left - right
|
||||
if isinstance(node.op, ast.Mult):
|
||||
return left * right
|
||||
if isinstance(node.op, ast.Div):
|
||||
return left / right
|
||||
# 负数底 + 非整数指数会得到复数,数学绘图不支持,抛 ValueError 让采样点作为断点处理
|
||||
if left < 0 and not right.is_integer():
|
||||
raise ValueError("negative base with fractional exponent")
|
||||
return left**right
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
value = _eval_node(node.operand, x)
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
if isinstance(node, ast.Call):
|
||||
args = [_eval_node(arg, x) for arg in node.args]
|
||||
return _FUNCTION_IMPL[node.func.id](*args) # type: ignore[operator]
|
||||
raise ValueError("unreachable node")
|
||||
|
||||
|
||||
def _strip_comment(line: str) -> str:
|
||||
return line.split("#", 1)[0].strip()
|
||||
|
||||
|
||||
def _parse_pair(value: str) -> tuple[float, float]:
|
||||
"""解析 ``min, max`` / ``min max`` 数值对。"""
|
||||
parts = [p for p in re.split(r"[,,\s]+", value.strip()) if p]
|
||||
if len(parts) != 2:
|
||||
raise ValueError("需要两个数值")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def _parse_directive(line: str) -> tuple[str, str] | None:
|
||||
"""指令行形如 ``key: value``(表达式不含冒号,冒号是可靠判别)。"""
|
||||
if ":" not in line or "=" in line:
|
||||
return None
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip().lower()
|
||||
if not key or " " in key:
|
||||
return None
|
||||
return key, value.strip()
|
||||
|
||||
|
||||
def parse_source(source: str) -> FunctionPlotParseResult:
|
||||
"""把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。"""
|
||||
diagnostics: list[PlotDiagnostic] = []
|
||||
expressions: list[FunctionPlotExpression] = []
|
||||
domain: tuple[float, float] = (-10.0, 10.0)
|
||||
range_: tuple[float, float] | None = None
|
||||
xlabel: str | None = None
|
||||
ylabel: str | None = None
|
||||
grid: bool = True
|
||||
has_error = False
|
||||
total_nodes = 0
|
||||
|
||||
for lineno, raw_line in enumerate(source.splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
directive = _parse_directive(line)
|
||||
if directive is not None:
|
||||
key, value = directive
|
||||
if key == "domain":
|
||||
try:
|
||||
domain = _parse_pair(value)
|
||||
except ValueError:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="warning",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"domain 需要两个数值,已忽略:{value!r}",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
elif key == "range":
|
||||
try:
|
||||
range_ = _parse_pair(value)
|
||||
except ValueError:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="warning",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"range 需要两个数值,已忽略:{value!r}",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
elif key == "xlabel":
|
||||
xlabel = value or None
|
||||
elif key == "ylabel":
|
||||
ylabel = value or None
|
||||
elif key == "grid":
|
||||
grid = value.lower() in ("true", "1", "yes", "on")
|
||||
else:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="warning",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message=f"未知指令 {key!r} 已忽略",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# 表达式行:y = <expr> 或裸 <expr>
|
||||
expr_text = _strip_comment(line)
|
||||
if not expr_text:
|
||||
continue
|
||||
if "=" in expr_text:
|
||||
lhs, _, rhs = expr_text.partition("=")
|
||||
if lhs.strip().lower() not in ("y", ""):
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="表达式应形如 'y = <expr>'",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
has_error = True
|
||||
continue
|
||||
expr_text = rhs.strip()
|
||||
if not expr_text:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="表达式为空",
|
||||
line=lineno,
|
||||
)
|
||||
)
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
try:
|
||||
tree = parse_expression(expr_text)
|
||||
except PlotParseError as exc:
|
||||
exc.diagnostic.line = lineno
|
||||
diagnostics.append(exc.diagnostic)
|
||||
has_error = True
|
||||
continue
|
||||
total_nodes += _count_nodes(tree.body)
|
||||
expressions.append(FunctionPlotExpression(expression=expr_text))
|
||||
# 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值
|
||||
if len(expressions) > _MAX_EXPRESSIONS:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_TOO_MANY_EXPRESSIONS",
|
||||
message=f"表达式数量超过上限 {_MAX_EXPRESSIONS},已回退为源码占位",
|
||||
)
|
||||
)
|
||||
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
|
||||
|
||||
if has_error:
|
||||
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
|
||||
if not expressions:
|
||||
diagnostics.append(
|
||||
PlotDiagnostic(
|
||||
severity="error",
|
||||
code="FUNCTION_PLOT_PARSE_FAILED",
|
||||
message="没有找到任何函数表达式",
|
||||
)
|
||||
)
|
||||
return FunctionPlotParseResult(plot=None, diagnostics=diagnostics)
|
||||
|
||||
plot = FunctionPlot(
|
||||
expressions=expressions,
|
||||
domain=domain,
|
||||
range=range_,
|
||||
axes=PlotAxes(xlabel=xlabel, ylabel=ylabel, grid=grid),
|
||||
node_count=total_nodes,
|
||||
)
|
||||
return FunctionPlotParseResult(plot=plot, diagnostics=diagnostics)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Function Plot → 静态 SVG 渲染。
|
||||
|
||||
只输出纯几何与 <text> 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。
|
||||
所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
from app.plot.model import FunctionPlot, StaticRenderResult
|
||||
from app.plot.parser import PlotParseError, evaluate, parse_expression
|
||||
|
||||
_WIDTH = 640
|
||||
_HEIGHT = 480
|
||||
_MARGIN = 52 # 四周留白,放轴刻度与标签
|
||||
_SAMPLES = 400
|
||||
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
|
||||
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
|
||||
|
||||
|
||||
def _safe_color(color: str | None, fallback: str) -> str:
|
||||
return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback
|
||||
|
||||
|
||||
def _valid_span(lo: float, hi: float) -> bool:
|
||||
"""范围跨度有效:端点有限、跨度有限且大于零。
|
||||
|
||||
端点相减可能溢出为 ``inf``(如 ``-1e308`` 到 ``1e308``),需单独校验跨度,
|
||||
否则后续坐标换算会生成含 ``nan`` 的 SVG。
|
||||
"""
|
||||
span = hi - lo
|
||||
return math.isfinite(lo) and math.isfinite(hi) and math.isfinite(span) and span > 0
|
||||
|
||||
|
||||
def _fmt_num(v: float) -> str:
|
||||
if v == 0:
|
||||
return "0"
|
||||
if abs(v) >= 1e6 or abs(v) < 1e-6:
|
||||
return f"{v:.2e}"
|
||||
return f"{v:.6g}"
|
||||
|
||||
|
||||
def _nice_step(span: float, target_ticks: int = 6) -> float:
|
||||
raw = abs(span) / target_ticks
|
||||
if not math.isfinite(raw) or raw <= 0:
|
||||
return 1.0 # 兜底步长,避免 span 为 0/inf 时产生非法刻度
|
||||
mag = 10 ** math.floor(math.log10(raw))
|
||||
for m in (1, 2, 5, 10):
|
||||
if raw <= m * mag:
|
||||
return m * mag
|
||||
return 10 * mag
|
||||
|
||||
|
||||
def _ticks(lo: float, hi: float, step: float) -> list[float]:
|
||||
# 防御:非法步长直接返回空,避免除零
|
||||
if not math.isfinite(step) or step <= 0:
|
||||
return []
|
||||
first = math.ceil(lo / step) * step
|
||||
values: list[float] = []
|
||||
v = first
|
||||
# 有上限的整数索引推进 + 步长推进校验,防止浮点精度导致 v+step==v 的死循环
|
||||
for _ in range(1000):
|
||||
if v > hi + step * 1e-9:
|
||||
break
|
||||
values.append(v)
|
||||
nxt = v + step
|
||||
if nxt <= v:
|
||||
break # 步长小于当前数值的浮点精度,已无法推进
|
||||
v = nxt
|
||||
return values
|
||||
|
||||
|
||||
def _compute_range(
|
||||
fns: list[tuple[object, object]],
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
) -> tuple[float, float]:
|
||||
"""采样确定 y 范围;取有限样本的 min/max 加 5% 余量。"""
|
||||
ys: list[float] = []
|
||||
for _expr, tree in fns:
|
||||
for i in range(_SAMPLES + 1):
|
||||
x = xmin + (xmax - xmin) * i / _SAMPLES
|
||||
try:
|
||||
y = evaluate(tree, x) # type: ignore[arg-type]
|
||||
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
|
||||
continue
|
||||
# 复数等非实数结果直接跳过,不参与范围统计
|
||||
if isinstance(y, (int, float)) and math.isfinite(y):
|
||||
ys.append(y)
|
||||
|
||||
if not ys:
|
||||
return -10.0, 10.0
|
||||
lo, hi = min(ys), max(ys)
|
||||
if lo == hi:
|
||||
lo -= 1.0
|
||||
hi += 1.0
|
||||
pad = (hi - lo) * 0.05
|
||||
return lo - pad, hi + pad
|
||||
|
||||
|
||||
def _polyline(
|
||||
tree: object,
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
sx: Callable[[float], float],
|
||||
sy: Callable[[float], float],
|
||||
color: str,
|
||||
) -> str:
|
||||
"""采样并把非有限点处断开成多段 polyline,避免画穿渐近线。"""
|
||||
segments: list[str] = []
|
||||
points: list[str] = []
|
||||
for i in range(_SAMPLES + 1):
|
||||
x = xmin + (xmax - xmin) * i / _SAMPLES
|
||||
try:
|
||||
y = evaluate(tree, x) # type: ignore[arg-type]
|
||||
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
|
||||
y = math.nan
|
||||
if not isinstance(y, (int, float)) or not math.isfinite(y):
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
points = []
|
||||
continue
|
||||
px = sx(x)
|
||||
py = sy(y)
|
||||
# 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf
|
||||
if not (math.isfinite(px) and math.isfinite(py)):
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
points = []
|
||||
continue
|
||||
points.append(f"{px:.2f},{py:.2f}")
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
return "".join(segments)
|
||||
|
||||
|
||||
def _grid(
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
sx: Callable[[float], float],
|
||||
sy: Callable[[float], float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
|
||||
parts.append(f'<line x1="{sx(x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(x):.2f}" y2="{sy(ymax):.2f}" stroke="#eaeef2"/>')
|
||||
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||
parts.append(f'<line x1="{sx(xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(y):.2f}" stroke="#eaeef2"/>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _axes(
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
sx: Callable[[float], float],
|
||||
sy: Callable[[float], float],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
|
||||
x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
|
||||
y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
|
||||
parts.append(
|
||||
f'<line x1="{sx(xmin):.2f}" y1="{sy(x_axis_y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(x_axis_y):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<line x1="{sx(y_axis_x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(y_axis_x):.2f}" y2="{sy(ymax):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
# x 轴刻度数字(画在轴下方)
|
||||
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
|
||||
parts.append(
|
||||
f'<text x="{sx(x):.2f}" y="{sy(x_axis_y) + 14:.2f}" text-anchor="middle" font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
|
||||
)
|
||||
# y 轴刻度数字(画在轴左侧)
|
||||
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||
parts.append(
|
||||
f'<text x="{sx(y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _labels(plot: FunctionPlot, sx: Callable[[float], float], sy: Callable[[float], float]) -> str:
|
||||
parts: list[str] = []
|
||||
if plot.axes.xlabel:
|
||||
parts.append(
|
||||
f'<text x="{(_WIDTH / 2):.2f}" y="{_HEIGHT - 10:.2f}" text-anchor="middle" font-size="12" fill="#1f2328">{html.escape(plot.axes.xlabel)}</text>'
|
||||
)
|
||||
if plot.axes.ylabel:
|
||||
parts.append(
|
||||
f'<text x="16" y="{(_HEIGHT / 2):.2f}" text-anchor="middle" font-size="12" fill="#1f2328" transform="rotate(-90 16 {_HEIGHT / 2:.2f})">{html.escape(plot.axes.ylabel)}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def render_svg(plot: FunctionPlot) -> StaticRenderResult:
|
||||
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
|
||||
warnings: list[str] = []
|
||||
xmin, xmax = plot.domain
|
||||
if not _valid_span(xmin, xmax):
|
||||
warnings.append("domain 无效,回退到 [-10, 10]")
|
||||
xmin, xmax = -10.0, 10.0
|
||||
|
||||
# 重新解析并编译表达式(parse_source 已校验,这里异常只在模型被绕过时触发)
|
||||
fns: list[tuple[object, object]] = []
|
||||
for expr in plot.expressions:
|
||||
try:
|
||||
tree = parse_expression(expr.expression)
|
||||
except PlotParseError as exc:
|
||||
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})")
|
||||
continue
|
||||
fns.append((expr, tree))
|
||||
|
||||
# 纵轴范围:显式 range 有效则用之;无效(退化/非有限/跨度溢出)丢弃并自动采样重算
|
||||
if plot.range is not None:
|
||||
lo, hi = float(plot.range[0]), float(plot.range[1])
|
||||
if _valid_span(lo, hi):
|
||||
ymin, ymax = lo, hi
|
||||
else:
|
||||
warnings.append("range 无效,改用自动范围")
|
||||
ymin, ymax = _compute_range(fns, xmin, xmax)
|
||||
else:
|
||||
ymin, ymax = _compute_range(fns, xmin, xmax)
|
||||
|
||||
# 最终防线:自动范围在极端样本下也可能溢出,坐标映射前必须保证跨度有限且大于零
|
||||
if not _valid_span(ymin, ymax):
|
||||
warnings.append("y 范围跨度无法表示,回退到 [-10, 10]")
|
||||
ymin, ymax = -10.0, 10.0
|
||||
|
||||
def sx(x: float) -> float:
|
||||
return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
|
||||
|
||||
def sy(y: float) -> float:
|
||||
return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
|
||||
|
||||
parts: list[str] = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_WIDTH} {_HEIGHT}" role="img">'
|
||||
]
|
||||
if plot.axes.grid:
|
||||
parts.append(_grid(xmin, xmax, ymin, ymax, sx, sy))
|
||||
parts.append(_axes(xmin, xmax, ymin, ymax, sx, sy))
|
||||
for i, (expr, tree) in enumerate(fns):
|
||||
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
|
||||
parts.append(_polyline(tree, xmin, xmax, sx, sy, color))
|
||||
parts.append(_labels(plot, sx, sy))
|
||||
parts.append("</svg>")
|
||||
|
||||
return StaticRenderResult(
|
||||
content="".join(parts),
|
||||
width=_WIDTH,
|
||||
height=_HEIGHT,
|
||||
warnings=warnings,
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""StaticRenderer 内部契约(契约 §10.4)。
|
||||
|
||||
把「静态可视化」抽象为统一请求/协议:导出器只面向 StaticRenderer,不再直接调用
|
||||
``render_svg`` 等具体实现。后端当前仅能静态渲染函数图像;Mermaid 后端无渲染能力,
|
||||
返回占位结果交前端渲染。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.plot.model import FunctionPlot, FunctionPlotParseResult, StaticRenderResult
|
||||
from app.plot.parser import parse_source
|
||||
from app.plot.render import render_svg
|
||||
|
||||
|
||||
class StaticRenderRequest(BaseModel):
|
||||
"""一次静态渲染请求;source_hash 供缓存/去重,theme 供主题化渲染。"""
|
||||
|
||||
kind: Literal["function_plot", "mermaid"]
|
||||
source: str
|
||||
source_hash: str = ""
|
||||
theme: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
|
||||
|
||||
class StaticRenderer(Protocol):
|
||||
"""静态渲染器协议:请求 → 渲染结果(content 为可直接内嵌的标记)。"""
|
||||
|
||||
def render(self, request: StaticRenderRequest) -> StaticRenderResult: ...
|
||||
|
||||
|
||||
class FunctionPlotStaticRenderer:
|
||||
"""函数图像渲染器:parse_source 解析 → render_svg 输出内嵌 SVG。
|
||||
|
||||
``parse`` 与 ``render_plot`` 拆开,供导出器在渲染前先拿 node_count 做文档级
|
||||
累计复杂度预算、并消费解析诊断。
|
||||
"""
|
||||
|
||||
def parse(self, request: StaticRenderRequest) -> FunctionPlotParseResult:
|
||||
return parse_source(request.source)
|
||||
|
||||
def render(self, request: StaticRenderRequest) -> StaticRenderResult:
|
||||
parsed = self.parse(request)
|
||||
if parsed.plot is None:
|
||||
raise ValueError("function-plot source has no valid plot")
|
||||
return self.render_plot(parsed.plot)
|
||||
|
||||
def render_plot(self, plot: FunctionPlot) -> StaticRenderResult:
|
||||
return render_svg(plot)
|
||||
|
||||
|
||||
class MermaidStaticRenderer:
|
||||
"""Mermaid 后端无渲染能力:返回空占位结果,交前端渲染。"""
|
||||
|
||||
def render(self, request: StaticRenderRequest) -> StaticRenderResult:
|
||||
return StaticRenderResult(
|
||||
content="",
|
||||
mime_type="text/plain",
|
||||
width=0,
|
||||
height=0,
|
||||
warnings=["mermaid 需前端渲染,已保留为占位代码块"],
|
||||
)
|
||||
+83
-1
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.container import container
|
||||
@@ -57,6 +57,11 @@ from app.contracts import (
|
||||
ModelRoutingResponse,
|
||||
SpeakerMatchRequest,
|
||||
SpeakerMatchResult,
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportJobListResponse,
|
||||
ExportRequest,
|
||||
ExportStatus,
|
||||
Note,
|
||||
NoteCreateRequest,
|
||||
NoteListResponse,
|
||||
@@ -105,9 +110,11 @@ from app.contracts import (
|
||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||
from app.benchmarks import datasets as benchmark_datasets
|
||||
from app.benchmarks import service as benchmark_service
|
||||
from app.config import get_settings
|
||||
from app.container import container
|
||||
from app.services.persona_settings import PersonaSettings, load_persona, save_persona
|
||||
from app.errors import ApiError
|
||||
from app.export import service as export_service
|
||||
from app.extensions import ExtensionError
|
||||
from app.extensions.mcp_registry import McpRegistryError
|
||||
from app.providers.base import ProviderError
|
||||
@@ -1507,6 +1514,81 @@ async def get_benchmark_report(run_id: str) -> BenchmarkReport:
|
||||
return report
|
||||
|
||||
|
||||
@router.post(
|
||||
"/exports",
|
||||
response_model=ExportJob,
|
||||
status_code=202,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def create_export(request: ExportRequest) -> ExportJob:
|
||||
return await export_service.create_export(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports",
|
||||
response_model=ExportJobListResponse,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def list_exports(
|
||||
status: ExportStatus | None = Query(default=None),
|
||||
format: ExportFormat | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ExportJobListResponse:
|
||||
items, total = export_service.list_exports(
|
||||
status=status, format=format, limit=limit, offset=offset
|
||||
)
|
||||
return ExportJobListResponse(
|
||||
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports/{job_id}",
|
||||
response_model=ExportJob,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def get_export(job_id: str) -> ExportJob:
|
||||
job = export_service.get_export(job_id)
|
||||
if job is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exports/{job_id}/file",
|
||||
tags=["Export"],
|
||||
)
|
||||
async def get_export_file(job_id: str) -> FileResponse:
|
||||
path = export_service.get_export_file(job_id) # 未完成/过期分别抛 404/410
|
||||
job = export_service.get_export(job_id)
|
||||
if job is None or job.file is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export file not ready", {"job_id": job_id}
|
||||
)
|
||||
return FileResponse(
|
||||
path=path,
|
||||
media_type=job.file.mime_type,
|
||||
filename=job.file.file_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/exports/{job_id}/cancel",
|
||||
response_model=OperationResponse,
|
||||
tags=["Export"],
|
||||
)
|
||||
async def cancel_export(job_id: str) -> OperationResponse:
|
||||
job = export_service.cancel_export(job_id)
|
||||
if job is None:
|
||||
raise ApiError(
|
||||
404, "EXPORT_JOB_NOT_FOUND", "export job not found", {"job_id": job_id}
|
||||
)
|
||||
return OperationResponse(
|
||||
status="accepted", resource_id=job_id, message="Export cancellation accepted."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings/persona", response_model=PersonaSettings, tags=["Settings"])
|
||||
|
||||
@@ -9,8 +9,11 @@ dependencies = [
|
||||
"fastapi>=0.116,<1.0",
|
||||
"httpx>=0.28,<1.0",
|
||||
"jsonschema>=4.25,<5.0",
|
||||
"mistune>=3.0,<4.0",
|
||||
"python-docx>=1.1,<2.0",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
"referencing>=0.36,<1.0",
|
||||
"reportlab>=4.0,<5.0",
|
||||
"sqlite-vec>=0.1.9",
|
||||
"uvicorn[standard]>=0.35,<1.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Export Service 的单元与端到端测试。
|
||||
|
||||
沿用 conftest 隔离机制:APP_DATA_DIR / DB / Vault / exports 目录都落在临时目录,
|
||||
不读写真实数据。导出采用「创建即 queued + 后台 Task 执行」的异步模型,测试在同一
|
||||
事件循环内创建并等待后台任务结束,得到终态 ExportJob 后再断言。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import get_settings
|
||||
from app.contracts import (
|
||||
ExportFormat,
|
||||
ExportJob,
|
||||
ExportOptions,
|
||||
ExportRequest,
|
||||
ExportSource,
|
||||
ExportSourceType,
|
||||
ExportStatus,
|
||||
)
|
||||
from app.errors import ApiError
|
||||
from app.export import service as export_service
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.markdown import parse_document
|
||||
|
||||
MD = """# 进程调度
|
||||
|
||||
一些 **加粗** 和 *斜体*,[链接](https://a.b) 与 `code`。
|
||||
|
||||
- 项目一
|
||||
- 项目二
|
||||
|
||||
```python
|
||||
print(1)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
```
|
||||
|
||||
```function_plot
|
||||
y = x
|
||||
```
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
行内 $x^2$ 与块级
|
||||
$$
|
||||
y = mx + b
|
||||
$$
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_export_state():
|
||||
"""清空内存注册表,避免跨用例的任务/取消标志互相污染。"""
|
||||
export_service._jobs.clear()
|
||||
export_service._tasks.clear()
|
||||
export_service._cancel_flags.clear()
|
||||
yield
|
||||
export_service._jobs.clear()
|
||||
export_service._tasks.clear()
|
||||
export_service._cancel_flags.clear()
|
||||
|
||||
|
||||
def _create_and_wait(request: ExportRequest) -> object:
|
||||
"""创建导出并在同一事件循环内等待后台任务结束,返回终态 ExportJob。"""
|
||||
|
||||
async def _execute():
|
||||
job = await export_service.create_export(request)
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
return asyncio.run(_execute())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# markdown → Document AST
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _types(nodes) -> list[str]:
|
||||
return [n.type for n in nodes]
|
||||
|
||||
|
||||
def test_parse_document_heading_and_inline() -> None:
|
||||
doc = parse_document("# 标题\n\n一段 **加粗** 和 [链接](https://a.b)。")
|
||||
|
||||
assert doc.type == "document"
|
||||
heading = doc.children[0]
|
||||
assert heading.type == "heading"
|
||||
assert heading.attributes["level"] == 1
|
||||
|
||||
para = doc.children[1]
|
||||
assert para.type == "paragraph"
|
||||
kinds = _types(para.children)
|
||||
assert "text" in kinds
|
||||
assert "strong" in kinds
|
||||
assert "link" in kinds
|
||||
|
||||
link = next(c for c in para.children if c.type == "link")
|
||||
assert link.attributes["href"] == "https://a.b"
|
||||
|
||||
|
||||
def test_parse_document_list_and_code_fencing() -> None:
|
||||
doc = parse_document("- a\n- b\n\n```mermaid\ngraph LR\n```\n\n```function_plot\ny=x\n```\n\n```python\nx\n```")
|
||||
|
||||
kinds = [c.type for c in doc.children]
|
||||
assert kinds[0] == "list"
|
||||
assert kinds[1] == "mermaid"
|
||||
assert kinds[2] == "function_plot"
|
||||
assert kinds[3] == "code_block"
|
||||
|
||||
code = doc.children[3]
|
||||
assert code.attributes["language"] == "python"
|
||||
assert code.text == "x"
|
||||
|
||||
|
||||
def test_parse_document_table_and_math() -> None:
|
||||
doc = parse_document("| a | b |\n|---|---|\n| 1 | 2 |\n\n$x^2$\n\n$$\ny=mx\n$$")
|
||||
|
||||
table = doc.children[0]
|
||||
assert table.type == "table"
|
||||
assert table.children[0].type == "table_row"
|
||||
assert table.children[0].children[0].attributes["head"] is True
|
||||
|
||||
# 表格后是「行内数学所在段落」与「块级数学」
|
||||
kinds = [c.type for c in doc.children[1:]]
|
||||
assert "paragraph" in kinds
|
||||
assert "math_block" in kinds
|
||||
|
||||
|
||||
def test_parse_document_image_maps_src_alt_title() -> None:
|
||||
doc = parse_document('')
|
||||
img = doc.children[0].children[0]
|
||||
assert img.type == "image"
|
||||
assert img.attributes["src"] == "https://a.b/img.png"
|
||||
assert img.attributes["alt"] == "替代文本"
|
||||
assert img.attributes["title"] == "标题"
|
||||
|
||||
|
||||
def test_parse_document_function_plot_dash_alias() -> None:
|
||||
doc = parse_document("```function-plot\ny = x^2\n```")
|
||||
assert doc.children[0].type == "function_plot"
|
||||
assert doc.children[0].text == "y = x^2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HtmlExporter
|
||||
# --------------------------------------------------------------------------- #
|
||||
async def _render(markdown: str, *, title: str = "") -> str:
|
||||
doc = parse_document(markdown)
|
||||
doc.attributes["title"] = title
|
||||
result = await HtmlExporter().export(doc, ExportOptions())
|
||||
return result.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_html_exporter_renders_basic_nodes_and_escapes() -> None:
|
||||
html = asyncio.run(_render("# 标题\n\n**加粗** [链接](https://a.b) 与 <b>原始</b>。"))
|
||||
|
||||
assert "<h1>标题</h1>" in html
|
||||
assert "<strong>加粗</strong>" in html
|
||||
assert '<a href="https://a.b">链接</a>' in html
|
||||
# 原始 HTML 必须被转义,不能注入文档
|
||||
assert "<b>原始</b>" in html
|
||||
assert "<b>原始</b>" not in html
|
||||
|
||||
|
||||
def test_html_exporter_marks_mermaid_and_function_plot() -> None:
|
||||
result = asyncio.run(HtmlExporter().export(parse_document("```mermaid\ngraph LR\n```"), ExportOptions()))
|
||||
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="mermaid">graph LR</pre>' in html
|
||||
assert any("mermaid" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_rejects_unsafe_link_protocol() -> None:
|
||||
result = asyncio.run(
|
||||
HtmlExporter().export(parse_document("[点我](javascript:alert(1))"), ExportOptions())
|
||||
)
|
||||
html = result.content.decode("utf-8")
|
||||
assert "javascript:" not in html
|
||||
assert "点我" in html
|
||||
assert any("不安全" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_rejects_unsafe_image_protocol() -> None:
|
||||
result = asyncio.run(
|
||||
HtmlExporter().export(parse_document(""), ExportOptions())
|
||||
)
|
||||
html = result.content.decode("utf-8")
|
||||
assert "data:" not in html
|
||||
assert "<img" not in html
|
||||
assert "alt" in html
|
||||
assert any("不安全" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_preserves_raw_html_block() -> None:
|
||||
result = asyncio.run(
|
||||
HtmlExporter().export(parse_document("<div>重要正文</div>"), ExportOptions())
|
||||
)
|
||||
html = result.content.decode("utf-8")
|
||||
assert "重要正文" in html
|
||||
assert "<div>" not in html
|
||||
assert "<div>重要正文</div>" in html
|
||||
assert any("原始 HTML" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_include_title_and_metadata() -> None:
|
||||
doc = parse_document("正文")
|
||||
doc.attributes["title"] = "操作系统复习"
|
||||
doc.attributes["metadata"] = {"tags": ["os", "复习"]}
|
||||
|
||||
opts = ExportOptions(include_title=True, include_metadata=True)
|
||||
result = asyncio.run(HtmlExporter().export(doc, opts))
|
||||
html = result.content.decode("utf-8")
|
||||
|
||||
assert '<h1 class="title">操作系统复习</h1>' in html
|
||||
assert "os, 复习" in html
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ExportService
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _markdown_request(markdown: str, *, format: ExportFormat = ExportFormat.html) -> ExportRequest:
|
||||
return ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.markdown, markdown=markdown),
|
||||
format=format,
|
||||
)
|
||||
|
||||
|
||||
def test_export_markdown_source_completes_and_writes_file() -> None:
|
||||
finished = _create_and_wait(_markdown_request(MD))
|
||||
|
||||
assert finished.status == ExportStatus.completed
|
||||
assert finished.file is not None
|
||||
assert finished.file.mime_type == "text/html"
|
||||
assert finished.file.size > 0
|
||||
assert len(finished.file.sha256) == 64
|
||||
|
||||
path = get_settings().exports_path / f"{finished.job_id}.html"
|
||||
assert path.exists()
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "进程调度" in content
|
||||
|
||||
|
||||
def test_export_note_source_resolves_title_and_metadata() -> None:
|
||||
from app.services import note_service
|
||||
|
||||
async def _go():
|
||||
note = await note_service.create_note(
|
||||
title="操作系统复习", markdown="# 进程调度\n\n内容。", folder="导出", tags=["os"]
|
||||
)
|
||||
request = ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.note, note_id=note.note_id),
|
||||
format=ExportFormat.html,
|
||||
options=ExportOptions(include_metadata=True),
|
||||
)
|
||||
job = await export_service.create_export(request)
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert finished.status == ExportStatus.completed
|
||||
assert finished.file is not None
|
||||
assert finished.file.file_name == "操作系统复习.html"
|
||||
content = (get_settings().exports_path / f"{finished.job_id}.html").read_text(encoding="utf-8")
|
||||
assert "操作系统复习" in content
|
||||
assert "进程调度" in content
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PDF / DOCX 导出
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_export_pdf_completes_with_pdf_magic_bytes() -> None:
|
||||
finished = _create_and_wait(_markdown_request(MD, format=ExportFormat.pdf))
|
||||
|
||||
assert finished.status == ExportStatus.completed
|
||||
assert finished.file is not None
|
||||
assert finished.file.mime_type == "application/pdf"
|
||||
assert finished.file.file_name.endswith(".pdf")
|
||||
|
||||
path = get_settings().exports_path / f"{finished.job_id}.pdf"
|
||||
assert path.exists()
|
||||
assert path.read_bytes()[:4] == b"%PDF"
|
||||
|
||||
|
||||
def test_export_docx_completes_with_zip_magic_bytes() -> None:
|
||||
finished = _create_and_wait(_markdown_request(MD, format=ExportFormat.docx))
|
||||
|
||||
assert finished.status == ExportStatus.completed
|
||||
assert finished.file is not None
|
||||
assert finished.file.mime_type == (
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
assert finished.file.file_name.endswith(".docx")
|
||||
|
||||
path = get_settings().exports_path / f"{finished.job_id}.docx"
|
||||
assert path.exists()
|
||||
assert path.read_bytes()[:2] == b"PK"
|
||||
|
||||
|
||||
def test_pdf_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
md = "```mermaid\ngraph LR\n```\n\n```function_plot\ny = x\n```"
|
||||
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
|
||||
assert result.content[:4] == b"%PDF"
|
||||
assert any("mermaid" in w for w in result.warnings)
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_docx_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
|
||||
md = "```mermaid\ngraph LR\n```\n\n```function_plot\ny = x\n```"
|
||||
result = asyncio.run(DocxExporter().export(parse_document(md), ExportOptions()))
|
||||
assert result.content[:2] == b"PK"
|
||||
assert any("mermaid" in w for w in result.warnings)
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_pdf_exporter_embeds_cjk_font() -> None:
|
||||
from app.export.exporters.pdf import PdfExporter
|
||||
|
||||
doc = parse_document("# 进程调度\n\n一些中文正文。")
|
||||
doc.attributes["title"] = "操作系统复习"
|
||||
result = asyncio.run(PdfExporter().export(doc, ExportOptions(include_title=True)))
|
||||
assert result.content[:4] == b"%PDF"
|
||||
# 中文字体通过 STSong-Light CID 字体嵌入,PDF 内应引用该 BaseFont
|
||||
assert b"STSong-Light" in result.content
|
||||
|
||||
|
||||
def test_docx_exporter_contains_cjk_text() -> None:
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from app.export.exporters.docx import DocxExporter
|
||||
|
||||
doc = parse_document("# 进程调度\n\n一些中文正文。")
|
||||
result = asyncio.run(DocxExporter().export(doc, ExportOptions()))
|
||||
with zipfile.ZipFile(BytesIO(result.content)) as zf:
|
||||
xml = zf.read("word/document.xml")
|
||||
assert "进程调度".encode("utf-8") in xml
|
||||
|
||||
|
||||
def test_export_unknown_note_404() -> None:
|
||||
request = ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.note, note_id="note_missing"),
|
||||
format=ExportFormat.html,
|
||||
)
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(export_service.create_export(request))
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.code == "EXPORT_SOURCE_NOT_FOUND"
|
||||
|
||||
|
||||
def test_export_empty_markdown_invalid() -> None:
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(export_service.create_export(_markdown_request(" ")))
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.code == "EXPORT_OPTIONS_INVALID"
|
||||
|
||||
|
||||
def test_export_cancel_queued_job() -> None:
|
||||
async def _go():
|
||||
job = await export_service.create_export(_markdown_request("# x"))
|
||||
cancelled = export_service.cancel_export(job.job_id)
|
||||
assert cancelled is not None
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert finished.status == ExportStatus.cancelled
|
||||
assert finished.file is None
|
||||
|
||||
|
||||
def test_export_file_expired_410() -> None:
|
||||
async def _go():
|
||||
job = await export_service.create_export(_markdown_request("# x"))
|
||||
finished = await export_service.wait_for_export(job.job_id)
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
export_service._jobs[job.job_id] = finished.model_copy(
|
||||
update={"file": finished.file.model_copy(update={"expires_at": past})}
|
||||
)
|
||||
return job.job_id
|
||||
|
||||
job_id = asyncio.run(_go())
|
||||
path = get_settings().exports_path / f"{job_id}.html"
|
||||
with pytest.raises(ApiError) as exc:
|
||||
export_service.get_export_file(job_id)
|
||||
assert exc.value.status_code == 410
|
||||
assert exc.value.code == "EXPORT_FILE_EXPIRED"
|
||||
assert not path.exists() # 过期即清理产物文件
|
||||
assert export_service.get_export(job_id) is None # 内存记录一并清理
|
||||
|
||||
|
||||
def test_export_eviction_deletes_file() -> None:
|
||||
finished = _create_and_wait(_markdown_request("# 淘汰"))
|
||||
victim_path = get_settings().exports_path / f"{finished.job_id}.html"
|
||||
assert victim_path.exists()
|
||||
|
||||
# 塞满 MAX_JOBS 个终态任务,下一次 create 会淘汰最旧的终态(finished 最先插入)
|
||||
for i in range(export_service.MAX_JOBS):
|
||||
export_service._jobs[f"export_fake_{i}"] = ExportJob(
|
||||
job_id=f"export_fake_{i}",
|
||||
status=ExportStatus.completed,
|
||||
format=ExportFormat.html,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
_create_and_wait(_markdown_request("# 触发淘汰"))
|
||||
assert not victim_path.exists()
|
||||
|
||||
|
||||
def test_cleanup_orphan_files() -> None:
|
||||
exports_dir = get_settings().exports_path
|
||||
exports_dir.mkdir(parents=True, exist_ok=True)
|
||||
orphan = exports_dir / "export_orphan.html"
|
||||
orphan.write_text("stale", encoding="utf-8")
|
||||
|
||||
finished = _create_and_wait(_markdown_request("# 保留"))
|
||||
keep_path = exports_dir / f"{finished.job_id}.html"
|
||||
assert keep_path.exists()
|
||||
|
||||
removed = export_service.cleanup_orphan_files()
|
||||
assert removed >= 1
|
||||
assert not orphan.exists()
|
||||
assert keep_path.exists() # 仍在注册表中的任务文件保留
|
||||
|
||||
|
||||
def test_export_cancel_during_running(monkeypatch) -> None:
|
||||
import threading
|
||||
import time
|
||||
|
||||
real_parse = parse_document
|
||||
started = threading.Event()
|
||||
|
||||
def slow_parse(markdown: str):
|
||||
started.set()
|
||||
time.sleep(0.1)
|
||||
return real_parse(markdown)
|
||||
|
||||
monkeypatch.setattr(export_service, "parse_document", slow_parse)
|
||||
|
||||
async def _go():
|
||||
job = await export_service.create_export(_markdown_request("# 运行中取消"))
|
||||
while not started.is_set():
|
||||
await asyncio.sleep(0)
|
||||
export_service.cancel_export(job.job_id)
|
||||
return await export_service.wait_for_export(job.job_id)
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert finished.status == ExportStatus.cancelled
|
||||
assert finished.file is None
|
||||
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
|
||||
|
||||
|
||||
def test_export_list_and_get() -> None:
|
||||
finished = _create_and_wait(_markdown_request("# 列表测试"))
|
||||
|
||||
items, total = export_service.list_exports(limit=50, offset=0)
|
||||
assert total == 1
|
||||
assert items[0].job_id == finished.job_id
|
||||
|
||||
got = export_service.get_export(finished.job_id)
|
||||
assert got is not None and got.status == ExportStatus.completed
|
||||
|
||||
assert export_service.get_export("export_missing") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 契约校验
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_export_source_requires_matching_field() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExportSource(type=ExportSourceType.note, note_id=None)
|
||||
with pytest.raises(ValidationError):
|
||||
ExportSource(type=ExportSourceType.markdown, markdown=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:资源上限
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_export_note_source_size_limit(monkeypatch) -> None:
|
||||
# P1:note 源超出 MAX_MARKDOWN_CHARS 应在创建期拒绝,不进入后台渲染
|
||||
from app.services import note_service
|
||||
|
||||
monkeypatch.setattr(export_service, "MAX_MARKDOWN_CHARS", 10)
|
||||
|
||||
async def _go():
|
||||
note = await note_service.create_note(
|
||||
title="超长笔记", markdown="a" * 20, folder="导出", tags=[]
|
||||
)
|
||||
return await export_service.create_export(
|
||||
ExportRequest(
|
||||
source=ExportSource(type=ExportSourceType.note, note_id=note.note_id),
|
||||
format=ExportFormat.html,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ApiError) as exc:
|
||||
asyncio.run(_go())
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.code == "EXPORT_OPTIONS_INVALID"
|
||||
|
||||
|
||||
def test_export_output_too_large(monkeypatch) -> None:
|
||||
# P1:产物超出 MAX_EXPORT_BYTES 应标记 failed 且不落盘
|
||||
monkeypatch.setattr(export_service, "MAX_EXPORT_BYTES", 10)
|
||||
|
||||
finished = _create_and_wait(_markdown_request("# 产物超限"))
|
||||
assert finished.status == ExportStatus.failed
|
||||
assert finished.error_code == "EXPORT_OUTPUT_TOO_LARGE"
|
||||
assert finished.file is None
|
||||
assert not (get_settings().exports_path / f"{finished.job_id}.html").exists()
|
||||
|
||||
|
||||
def test_export_limits_concurrent_rendering(monkeypatch) -> None:
|
||||
# P1:并发渲染受 MAX_CONCURRENT_RENDERS 限制,大量任务不会同时占满工作线程
|
||||
import threading
|
||||
import time
|
||||
|
||||
real_render = export_service._render_document
|
||||
active = 0
|
||||
peak = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def slow_render(document, options, format):
|
||||
nonlocal active, peak
|
||||
with lock:
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
time.sleep(0.05)
|
||||
with lock:
|
||||
active -= 1
|
||||
return real_render(document, options, format)
|
||||
|
||||
monkeypatch.setattr(export_service, "_render_document", slow_render)
|
||||
|
||||
async def _go():
|
||||
jobs = [
|
||||
await export_service.create_export(_markdown_request(f"# t{i}"))
|
||||
for i in range(6)
|
||||
]
|
||||
return [await export_service.wait_for_export(j.job_id) for j in jobs]
|
||||
|
||||
finished = asyncio.run(_go())
|
||||
assert all(j.status == ExportStatus.completed for j in finished)
|
||||
assert peak <= export_service.MAX_CONCURRENT_RENDERS
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Function Plot 的解析与静态 SVG 渲染测试。
|
||||
|
||||
覆盖 parser 的白名单表达式(幂/隐式乘法/函数/常量)、拒绝项(属性访问、任意调用等)、
|
||||
parse_source 指令与回退,以及 render 的 SVG 输出与 HTML 导出链路集成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.contracts import ExportOptions
|
||||
from app.export.exporters.html import HtmlExporter
|
||||
from app.export.markdown import parse_document
|
||||
from app.plot.parser import PlotParseError, evaluate, parse_expression, parse_source
|
||||
from app.plot.render import render_svg
|
||||
from app.plot.renderer import (
|
||||
FunctionPlotStaticRenderer,
|
||||
MermaidStaticRenderer,
|
||||
StaticRenderRequest,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 表达式解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_expression_power_and_implicit_multiplication() -> None:
|
||||
assert evaluate(parse_expression("x^2"), 3) == 9.0
|
||||
assert evaluate(parse_expression("2^3"), 0) == 8.0
|
||||
assert evaluate(parse_expression("2x+1"), 3) == 7.0
|
||||
assert evaluate(parse_expression("2(x+1)"), 3) == 8.0
|
||||
assert evaluate(parse_expression("(x+1)(x-1)"), 3) == 8.0
|
||||
|
||||
|
||||
def test_parse_expression_functions_and_constants() -> None:
|
||||
assert evaluate(parse_expression("sin(0)"), 0) == 0.0
|
||||
assert math.isclose(evaluate(parse_expression("sin(pi/2)"), 0), 1.0)
|
||||
assert math.isclose(evaluate(parse_expression("ln(e)"), 0), 1.0)
|
||||
assert evaluate(parse_expression("abs(-3)"), 0) == 3.0
|
||||
|
||||
|
||||
def test_parse_expression_rejects_unsafe() -> None:
|
||||
unsafe = [
|
||||
"os.system('x')",
|
||||
"__import__('os')",
|
||||
"foo(x)",
|
||||
"eval('x')",
|
||||
"x[0]",
|
||||
"x.attr",
|
||||
"lambda: 1",
|
||||
]
|
||||
for expr in unsafe:
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression(expr)
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE", expr
|
||||
|
||||
|
||||
def test_parse_expression_syntax_error() -> None:
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression("x +")
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_PARSE_FAILED"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# fenced 源码解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_source_directives() -> None:
|
||||
result = parse_source("domain: 0, 10\nrange: -1, 1\nxlabel: x\ngrid: false\ny = x^2")
|
||||
assert result.plot is not None
|
||||
assert result.plot.domain == (0.0, 10.0)
|
||||
assert result.plot.range == (-1.0, 1.0)
|
||||
assert result.plot.axes.xlabel == "x"
|
||||
assert result.plot.axes.grid is False
|
||||
assert len(result.plot.expressions) == 1
|
||||
assert result.plot.expressions[0].expression == "x^2"
|
||||
|
||||
|
||||
def test_parse_source_bare_and_multi_expression() -> None:
|
||||
result = parse_source("x^2\nsin(x)")
|
||||
assert result.plot is not None
|
||||
assert [e.expression for e in result.plot.expressions] == ["x^2", "sin(x)"]
|
||||
|
||||
|
||||
def test_parse_source_unknown_directive_warns() -> None:
|
||||
result = parse_source("foo: bar\ny = x")
|
||||
assert result.plot is not None # 未知指令仅 warning,不阻断
|
||||
assert any(d.severity == "warning" for d in result.diagnostics)
|
||||
|
||||
|
||||
def test_parse_source_error_returns_no_plot() -> None:
|
||||
result = parse_source("y = os.system('x')")
|
||||
assert result.plot is None
|
||||
assert any(d.severity == "error" for d in result.diagnostics)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SVG 渲染
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_render_svg_contains_polyline_and_axes() -> None:
|
||||
plot = parse_source("y = x^2").plot
|
||||
rendered = render_svg(plot)
|
||||
svg = rendered.content
|
||||
assert "<svg" in svg
|
||||
assert "<polyline" in svg
|
||||
assert "<line" in svg # 坐标轴/网格
|
||||
assert "<script" not in svg
|
||||
assert rendered.width == 640
|
||||
assert rendered.height == 480
|
||||
|
||||
|
||||
def test_render_svg_multiple_functions() -> None:
|
||||
plot = parse_source("y = x^2\ny = sin(x)").plot
|
||||
rendered = render_svg(plot)
|
||||
assert rendered.content.count("<polyline") >= 2
|
||||
|
||||
|
||||
def test_render_svg_labels() -> None:
|
||||
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "时间" in rendered.content
|
||||
assert "数值" in rendered.content
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTML 导出链路集成
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_html_exporter_embeds_function_plot_svg() -> None:
|
||||
md = "```function-plot\ny = x^2\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<figure class="function-plot">' in html
|
||||
assert "<svg" in html
|
||||
assert "<polyline" in html
|
||||
|
||||
|
||||
def test_html_exporter_function_plot_fallback_on_error() -> None:
|
||||
md = "```function-plot\ny = os.system('x')\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="function-plot">' in html
|
||||
assert "<svg" not in html
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:浮点刻度 / 求值异常 / 无效范围
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_render_svg_huge_domain_ticks_bounded() -> None:
|
||||
# P1:巨大 domain 下步长受浮点精度限制无法推进,刻度应有限而非死循环
|
||||
plot = parse_source("domain: 10000000000000000, 10000000000000002\nrange: -1, 1\ny = 0").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<svg" in rendered.content
|
||||
|
||||
|
||||
def test_parse_expression_rejects_wrong_arg_count() -> None:
|
||||
# P2:sin() / sin(1, 2) 应在解析期拒绝,而非求值期 TypeError
|
||||
with pytest.raises(PlotParseError):
|
||||
parse_expression("sin()")
|
||||
with pytest.raises(PlotParseError):
|
||||
parse_expression("sin(1, 2)")
|
||||
|
||||
|
||||
def test_render_svg_nonreal_samples_are_break_points() -> None:
|
||||
# P2:x^0.5 在负数域产生复数,应作为断点处理,正半轴仍可绘制
|
||||
plot = parse_source("domain: -4, 4\ny = x^0.5").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<polyline" in rendered.content
|
||||
|
||||
|
||||
def test_render_svg_invalid_range_falls_back() -> None:
|
||||
# P2:退化 range(1, 1)应丢弃并自动采样,而非 ZeroDivisionError
|
||||
plot = parse_source("range: 1, 1\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<polyline" in rendered.content
|
||||
assert any("range" in w for w in rendered.warnings)
|
||||
|
||||
|
||||
def test_render_svg_nonfinite_range_falls_back() -> None:
|
||||
# P2:非有限 range 端点应丢弃并自动采样
|
||||
plot = parse_source("range: nan, 1\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<polyline" in rendered.content
|
||||
|
||||
|
||||
def test_html_exporter_function_plot_render_error_falls_back(monkeypatch) -> None:
|
||||
# P2:渲染异常不阻断整篇导出,回退占位并记 warning
|
||||
import app.plot.renderer as renderer_mod
|
||||
|
||||
def boom(plot):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(renderer_mod, "render_svg", boom)
|
||||
md = "```function-plot\ny = x\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="function-plot">' in html
|
||||
assert any("渲染失败" in w for w in result.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:复杂表达式 / 极端数值范围
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_expression_rejects_excessive_depth() -> None:
|
||||
# P2:超长加法链的 AST 深度超限,应拒绝为 PlotParseError 而非触发 RecursionError
|
||||
expr = "+".join(["1"] * 300)
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression(expr)
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
|
||||
|
||||
|
||||
def test_parse_expression_rejects_excessive_nodes() -> None:
|
||||
# P2:浅层但节点超限的表达式(满二叉树)应被节点数上限拦截
|
||||
def balanced(depth: int) -> str:
|
||||
if depth == 0:
|
||||
return "x"
|
||||
return f"({balanced(depth - 1)}+{balanced(depth - 1)})"
|
||||
|
||||
expr = balanced(10) # ~2047 个节点,深度仅 ~10
|
||||
with pytest.raises(PlotParseError) as exc:
|
||||
parse_expression(expr)
|
||||
assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
|
||||
|
||||
|
||||
def test_html_exporter_function_plot_deep_expression_falls_back() -> None:
|
||||
# P2:复杂表达式解析失败应回退占位,不阻断整篇导出
|
||||
expr = "+".join(["1"] * 300)
|
||||
md = f"```function-plot\ny = {expr}\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert '<pre class="function-plot">' in html
|
||||
assert "<svg" not in html
|
||||
assert any("函数图像" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_render_svg_extreme_domain_no_nan() -> None:
|
||||
# P2:有限但跨度溢出的 domain 应回退安全范围,SVG 不得含 nan/inf
|
||||
plot = parse_source("domain: -1e308, 1e308\nrange: -1, 1\ny = 0").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<svg" in rendered.content
|
||||
assert "nan" not in rendered.content
|
||||
assert "inf" not in rendered.content
|
||||
assert any("domain" in w for w in rendered.warnings)
|
||||
|
||||
|
||||
def test_render_svg_extreme_range_no_nan() -> None:
|
||||
# P2:有限但跨度溢出的 range 应回退自动范围,SVG 不得含 nan/inf
|
||||
plot = parse_source("domain: -1, 1\nrange: -1e308, 1e308\ny = x").plot
|
||||
rendered = render_svg(plot)
|
||||
assert "<svg" in rendered.content
|
||||
assert "nan" not in rendered.content
|
||||
assert "inf" not in rendered.content
|
||||
assert any("range" in w for w in rendered.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 审阅回归:函数/图像数量上限
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_parse_source_rejects_too_many_expressions() -> None:
|
||||
# P1:单块表达式数量超限应整块回退,避免海量采样求值
|
||||
source = "\n".join(f"y = x + {i}" for i in range(50))
|
||||
result = parse_source(source)
|
||||
assert result.plot is None
|
||||
assert any(d.code == "FUNCTION_PLOT_TOO_MANY_EXPRESSIONS" for d in result.diagnostics)
|
||||
|
||||
|
||||
def test_html_exporter_limits_function_plot_count() -> None:
|
||||
# P1:文档内函数图像数量超限,超出部分回退占位,不耗尽资源
|
||||
blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(blocks), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
# 上限 16:前 16 个渲染为 SVG,其余 4 个回退占位
|
||||
assert html.count('<figure class="function-plot">') == 16
|
||||
assert html.count('<pre class="function-plot">') == 4
|
||||
assert any("函数图像数量超过上限" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_html_exporter_limits_total_plot_nodes(monkeypatch) -> None:
|
||||
# P1:文档级累计 AST 节点预算超限后,后续图像回退占位,防止组合复杂度耗尽 CPU
|
||||
import app.export.exporters.html as html_mod
|
||||
|
||||
monkeypatch.setattr(html_mod, "_MAX_TOTAL_PLOT_NODES", 5)
|
||||
# 第一个图块 y=x(1 节点)在预算内;第二个图块 y=x+x+x+x(7 节点)累计超限
|
||||
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
|
||||
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
|
||||
html = result.content.decode("utf-8")
|
||||
assert html.count('<figure class="function-plot">') == 1
|
||||
assert html.count('<pre class="function-plot">') == 1
|
||||
assert any("累计复杂度" in w for w in result.warnings)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# StaticRenderer 内部契约(§10.4)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_function_plot_static_renderer_renders_svg() -> None:
|
||||
renderer = FunctionPlotStaticRenderer()
|
||||
result = renderer.render(StaticRenderRequest(kind="function_plot", source="y = x^2"))
|
||||
assert "<svg" in result.content
|
||||
assert "<polyline" in result.content
|
||||
assert result.mime_type == "image/svg+xml"
|
||||
assert result.width == 640
|
||||
assert result.height == 480
|
||||
|
||||
|
||||
def test_function_plot_static_renderer_parse_exposes_node_count() -> None:
|
||||
renderer = FunctionPlotStaticRenderer()
|
||||
parsed = renderer.parse(StaticRenderRequest(kind="function_plot", source="y = x + x"))
|
||||
assert parsed.plot is not None
|
||||
assert parsed.plot.node_count > 0
|
||||
|
||||
|
||||
def test_function_plot_static_renderer_raises_on_no_plot() -> None:
|
||||
renderer = FunctionPlotStaticRenderer()
|
||||
request = StaticRenderRequest(kind="function_plot", source="y = os.system('x')")
|
||||
with pytest.raises(ValueError):
|
||||
renderer.render(request)
|
||||
|
||||
|
||||
def test_mermaid_static_renderer_returns_placeholder() -> None:
|
||||
renderer = MermaidStaticRenderer()
|
||||
result = renderer.render(StaticRenderRequest(kind="mermaid", source="graph LR"))
|
||||
assert result.content == ""
|
||||
assert any("mermaid" in w for w in result.warnings)
|
||||
Generated
+405
@@ -149,6 +149,153 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
@@ -364,6 +511,147 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/f1/95133bde7af7afb1f5ba6090b674d826b7a518318bba54bbbb633b27865a/lxml-6.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c66f858b82497173f73366795fc6ee8171620e75a338506d6b2e7bc16f5fca11", size = 8563141, upload-time = "2026-09-02T14:46:42.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/54/5a79ee2181ac773ee13e48205411845feec69e1c3d097e985c1343171712/lxml-6.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:032a0a97eed428bd143c75a11118238546424ceb2fa311cca5f073aa44658dc4", size = 4613690, upload-time = "2026-09-02T14:46:45.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/29/8c24672f56807f119312f073f24204368574bd16b384ede861b5104b3a2b/lxml-6.1.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a579dfb9c835f8ab47f4b8ed33440cbc75b806b73297208e6ec2a33e903740b", size = 4935630, upload-time = "2026-09-02T14:46:48.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/69/ce2436d854c848c19fc9287143991f3fc76b8b4e9a0dbba8452e51dff264/lxml-6.1.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:49fbc2682a9306135b7ec49e93f97f9c26689b9b7f96ed2742d8d6497e994d13", size = 5079033, upload-time = "2026-09-02T14:46:50.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ec/b66f66f6499ad800265d57540b51e6632e3232d3526f42f2f8fd4b14e0ea/lxml-6.1.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea2c01cdb16dc12156e455007c406dfaaece0c89aa4ba0e3b47586779f951d41", size = 5012298, upload-time = "2026-09-02T14:46:52.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2a/25d128872f4d51753542bfc3feb482c2ea7c8a2d6d81a0bc5c6a00779ed4/lxml-6.1.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:527195c188d7d0af748cd48d220ab8cdc5cb99be3d49ac4d9be7324d8abf9bc0", size = 5211431, upload-time = "2026-09-02T14:46:54.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b2/0a41bbef074a556110f84fafb6d8c2998293c7d3bfbe1ce74515bc65393b/lxml-6.1.3-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:20384c2bbcbf87180c8c61eb60869699c1ec0cd09b62cfd13804022d860b0867", size = 5343417, upload-time = "2026-09-02T14:46:57.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/cd/16116c3f91791aeeeab1cbe6e7eb6e646f127be7b0158b262eb526a21a0c/lxml-6.1.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:424aa5657141d306ba9ad1baab4b2c0a0719040075ee6c66aee9bb2dea2b5054", size = 4673219, upload-time = "2026-09-02T14:46:59.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/bb/4dff849f443ef70221676aec938bc41e8bae6430aa2ca13b041319e14b98/lxml-6.1.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4736e6c87e603146d8949d8501da621ad20c31015060d3fcf95ace2859f3e3e6", size = 5281246, upload-time = "2026-09-02T14:47:02.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ac/4aa7dd059420bfd35278c7fe819e9d319ee36a0453b7bbde1907a7832d91/lxml-6.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6374e9e382e5a98c9c5e66d41b357b470da1c54bce30f17f9dc4bcc58436cc1c", size = 5055451, upload-time = "2026-09-02T14:47:05.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/44/20d90cf6f4234de9cd9eeb4f519419885fdb087fa80d073c7b57be342021/lxml-6.1.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:22eec57e26c418cde02c051ce9914a365e52a7f135a565c6f0480242aeebab48", size = 4722694, upload-time = "2026-09-02T14:47:08.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0e/6bee12325e53dd6613fe1e107def07583b6182ade03e94bfef8976622e44/lxml-6.1.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8753b8d51dbc86fd335ee31fcf7f3658e9f5c016d4edfb23f76ad295f4b8c9d0", size = 5269179, upload-time = "2026-09-02T14:47:10.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5d/54d269ce5cd0787c0424d9cef449ee794d4097725d13dd2acd6181c44e9c/lxml-6.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:207dfc3d47cf0e575e643bbc140dacc8863b39abaa1e5307cd64c7f2365b8a12", size = 5235559, upload-time = "2026-09-02T14:47:13.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f7/5a3095f187f1bec293591616a1677781acc265c5b313c009f8a19c471a09/lxml-6.1.3-cp311-cp311-win32.whl", hash = "sha256:18293f8a8d8b6a8e71ef37706b659e3846a4261232158167b1ddf35f6994f633", size = 3600377, upload-time = "2026-09-02T14:47:15.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/5a/15531a0d307c96282fe8b639b3d74e8bd783e4ab4cb2b0781146ac4161b8/lxml-6.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:7ae4949f212a53b007dbc355884fda122545c5764a54256c9217e419a62a6559", size = 4032700, upload-time = "2026-09-02T14:47:18.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/f9/8de76314955545ceaaa7c0305017b8aaa217905dee59c62c0e2c1e44a68f/lxml-6.1.3-cp311-cp311-win_arm64.whl", hash = "sha256:2123e5aa075ac20d23c7af489255efd129cbfe190dbe88fd42598cc9df3199b6", size = 3674431, upload-time = "2026-09-02T14:47:22.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c1/2433176de263cc3f51fd2c303f993d5bb7f1da3139a0f7d168116c0bfa7a/lxml-6.1.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d2765c18ce303149ee804b1f3dad11232726dd0a702d73a15cf19179ac8cc962", size = 3942969, upload-time = "2026-09-02T14:46:36.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/71/de7759096f480180fd9e43ff7c017860e2d2a9a43741ab093cbdf1820f07/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d5a748d12dd9b535e0a130f60dae9ddf0adafbabe61e7864f55c7436c84547a", size = 4213008, upload-time = "2026-09-02T14:46:38.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/9b/c2d09af47a34fa6c0c27473083812b449a411680bd04bbe609cde291ddc8/lxml-6.1.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41096ec0740a58dad03d3ae0c7486d306d20becefb13ceb1649835ab3eb64167", size = 4322012, upload-time = "2026-09-02T14:46:41.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f3/bf56fee0403ebd995be8e78ec9aca566016487d1b3cbf755ebea8ccffbdb/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:415e3a115c0d510e329020012834d1c0aa1c581ee53a218603e38abbc1dea70a", size = 4257402, upload-time = "2026-09-02T14:46:43.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/1d/6da9cc086a20d9dd6bcbf7c5d9575f0331cca9a05e67dab02d15e828170b/lxml-6.1.3-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20428910dae17a1a93152a3ff2c0441d2f4932992c0797d65651dd0561f1792f", size = 4410889, upload-time = "2026-09-02T14:46:46.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/5c/91fe48856f9f8089be3096fa4dbe4b3fb5526f3bf3e852ea9497f399cb9f/lxml-6.1.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bc8dd3d9c93e70c3df974a201ac2958b6d77b465d813c51d1f15fa8e645763ae", size = 3511258, upload-time = "2026-09-02T14:46:49.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mistune"
|
||||
version = "3.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notes-agent-backend"
|
||||
version = "0.1.0"
|
||||
@@ -373,8 +661,11 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mistune" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
{ name = "reportlab" },
|
||||
{ name = "sqlite-vec" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@@ -390,8 +681,11 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116,<1.0" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.25,<5.0" },
|
||||
{ name = "mistune", specifier = ">=3.0,<4.0" },
|
||||
{ name = "python-docx", specifier = ">=1.1,<2.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7.0" },
|
||||
{ name = "referencing", specifier = ">=0.36,<1.0" },
|
||||
{ name = "reportlab", specifier = ">=4.0,<5.0" },
|
||||
{ name = "sqlite-vec", specifier = ">=0.1.9" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1.0" },
|
||||
]
|
||||
@@ -408,6 +702,91 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -568,6 +947,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
@@ -646,6 +1038,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reportlab"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "pillow" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/3f/b3861b7e40c9d66f4a04e018958d681d16b948bfd1963c962d43a8c23f66/reportlab-4.5.1.tar.gz", hash = "sha256:9fdf68f4de9171ec66acb4a5feed8f8ca2af43479e707a6fbb0daa75d88e5494", size = 3939748, upload-time = "2026-05-12T10:14:13.663Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/45/ea7fad10122440de6e845568d106bffdc456ca0e8a1d8ae10b46016087e4/reportlab-4.5.1-py3-none-any.whl", hash = "sha256:06fce8cb56c83307cfa4909cdf4e6a2ddbb44e5d6ef4d2edca896d7e9769f091", size = 1957812, upload-time = "2026-05-12T10:14:10.622Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.6.3"
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
|
||||
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
|
||||
- [Benchmark 开发说明](development/Benchmark开发说明.md)
|
||||
- [Export 开发说明](development/Export开发说明.md)
|
||||
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
|
||||
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
|
||||
- [独立 MCP Server 配置中心开发说明](development/独立MCP-Server配置中心开发说明.md)
|
||||
|
||||
@@ -2365,7 +2365,7 @@ Quality
|
||||
└── Retrieval 参数调优
|
||||
|
||||
Content Output
|
||||
├── Markdown → HTML / PDF / DOCX
|
||||
├── Markdown → HTML(已实现)/ PDF / DOCX(暂缓)
|
||||
├── Mermaid 编辑、预览与静态导出
|
||||
└── Function Plot 解析、预览与静态导出
|
||||
|
||||
@@ -2376,7 +2376,7 @@ Frontend Extension
|
||||
└── Plugin Settings UI
|
||||
```
|
||||
|
||||
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG Benchmark 和 Provider 增强已经实现;Agent Benchmark、内容导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
上述列表描述第二阶段技术范围。多模态、MCP Bridge、Plugin Command/Settings、RAG Benchmark、Provider 增强与 Markdown → HTML 导出已经实现;Agent Benchmark、PDF/DOCX 导出、Mermaid/函数图像完整编辑导出及社区主题包仍以各自开发说明的状态为准。每项功能必须继续经过现有 Service、Contract、Permission 和 Adapter 边界,不因 Demo 需要在 Vue 组件、Router 或 Agent Runtime 中直接绑定第三方协议。
|
||||
|
||||
第三阶段处理:
|
||||
|
||||
|
||||
@@ -68,11 +68,11 @@
|
||||
| Benchmark | POST | `/api/benchmarks/agent/runs` | 暂缓 | 创建 Agent Benchmark(依赖 Agent Runtime 完成后交付) |
|
||||
| Benchmark | GET | `/api/benchmarks/runs` | 已实现 | 分页获取 Benchmark Run |
|
||||
| Benchmark | GET/POST | `/api/benchmarks/runs/{run_id}/*` | 计划新增 | 查询、订阅、取消和读取报告 |
|
||||
| Export | POST | `/api/exports` | 计划新增 | 创建 HTML/PDF/DOCX 导出任务 |
|
||||
| Export | GET | `/api/exports` | 计划新增 | 分页获取导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}` | 计划新增 | 查询导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}/file` | 计划新增 | 下载已完成产物 |
|
||||
| Export | POST | `/api/exports/{job_id}/cancel` | 计划新增 | 取消导出任务 |
|
||||
| Export | POST | `/api/exports` | 已实现(HTML) | 创建导出任务;`pdf`/`docx` 暂缓,返回 `EXPORT_FORMAT_UNSUPPORTED` |
|
||||
| Export | GET | `/api/exports` | 已实现(HTML) | 分页获取导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}` | 已实现(HTML) | 查询导出任务 |
|
||||
| Export | GET | `/api/exports/{job_id}/file` | 已实现(HTML) | 下载已完成产物 |
|
||||
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现(HTML) | 取消导出任务 |
|
||||
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
|
||||
| Renderer | 内部 Contract | `StaticRenderer` | 计划新增 | Mermaid/Function Plot 预览和导出复用 |
|
||||
|
||||
@@ -1080,6 +1080,8 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
|
||||
## 10. Export Service
|
||||
|
||||
> 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED`。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。
|
||||
|
||||
### 10.1 创建导出任务
|
||||
|
||||
`POST /api/exports`,返回 `202 ExportJob`。
|
||||
@@ -1090,7 +1092,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
"type": "note",
|
||||
"note_id": "note_123"
|
||||
},
|
||||
"format": "pdf",
|
||||
"format": "html",
|
||||
"options": {
|
||||
"theme_id": "light",
|
||||
"include_title": true,
|
||||
@@ -1101,7 +1103,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
}
|
||||
```
|
||||
|
||||
`source.type` 首批支持 `note` 和 `markdown`。`markdown` 来源用于尚未保存的预览,字段大小受限且不持久化到 Trace。`format` 固定为 `html`、`pdf`、`docx`。
|
||||
`source.type` 首批支持 `note` 和 `markdown`。`note` 来源通过 `source.note_id` 引用已建索引笔记;`markdown` 来源用于尚未保存的预览,内容放在 `source.markdown` 字段,大小限制为 200 000 字符、不持久化到 Trace。`format` 可取 `html`、`pdf`、`docx`,但当前仅 `html` 已实现,`pdf`/`docx` 返回 `EXPORT_FORMAT_UNSUPPORTED`。
|
||||
|
||||
响应:
|
||||
|
||||
@@ -1109,11 +1111,12 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
{
|
||||
"job_id": "export_123",
|
||||
"status": "queued",
|
||||
"format": "pdf",
|
||||
"format": "html",
|
||||
"progress": null,
|
||||
"file": null,
|
||||
"warnings": [],
|
||||
"error": null,
|
||||
"error_code": null,
|
||||
"created_at": "2026-08-31T10:30:00Z",
|
||||
"started_at": null,
|
||||
"completed_at": null
|
||||
@@ -1129,14 +1132,14 @@ VECTOR_INDEX_REBUILD_REQUIRED
|
||||
| POST | `/api/exports/{job_id}/cancel` | `OperationResponse` |
|
||||
| GET | `/api/exports/{job_id}/file` | 文件流 |
|
||||
|
||||
下载响应设置正确 `Content-Type`、经过清理的 `Content-Disposition` 文件名和 `Content-Length`。未完成、失败或过期 Job 不返回空文件。
|
||||
下载响应设置正确 `Content-Type`、经过清理的 `Content-Disposition` 文件名和 `Content-Length`。未完成、失败或过期的 Job 不返回空文件:未完成/失败返回 `EXPORT_JOB_NOT_FOUND`(404),产物过期(超过 `expires_at`)返回 `EXPORT_FILE_EXPIRED`(410)。
|
||||
|
||||
完成 Job 的 file:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_name": "操作系统复习.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"file_name": "操作系统复习.html",
|
||||
"mime_type": "text/html",
|
||||
"size": 1048576,
|
||||
"sha256": "...",
|
||||
"expires_at": "2026-09-01T10:30:00Z"
|
||||
@@ -1225,6 +1228,7 @@ EXPORT_RENDER_FAILED
|
||||
EXPORT_UNSUPPORTED_CONTENT
|
||||
EXPORT_JOB_NOT_FOUND
|
||||
EXPORT_FILE_EXPIRED
|
||||
EXPORT_OUTPUT_TOO_LARGE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Export 开发说明
|
||||
|
||||
> 所属模块:Export Service(后端,负责人 yxx)。交付「多格式文档导出」:Markdown → HTML / PDF / DOCX 的完整生命周期与 function-plot 静态 SVG 渲染。契约对应 [第二阶段接口契约 §10](../contracts/第二阶段接口契约-开发版.md)。
|
||||
|
||||
## 定位
|
||||
|
||||
Export Service 把笔记或未保存的 Markdown 文本渲染为可下载的 HTML 文件。采用与 Benchmark 一致的「创建即返回 queued、后台 asyncio.Task 执行」的内存模型,产物带 24h 过期时间,过期后不可下载。导出是轮询式(无 SSE 事件流),客户端通过 `GET /api/exports/{job_id}` 轮询状态,完成后走 `GET /api/exports/{job_id}/file` 下载。
|
||||
|
||||
## 模块布局
|
||||
|
||||
```text
|
||||
backend/app/export/
|
||||
├── __init__.py 包说明
|
||||
├── document.py Document AST 内部协议 + DocumentExporter Protocol + ExportResult
|
||||
├── markdown.py mistune 'ast' renderer → Document AST
|
||||
├── exporters/
|
||||
│ ├── __init__.py
|
||||
│ ├── _common.py 共享工具(URL 协议校验 + 占位 warning 文案 + 元数据格式化)
|
||||
│ ├── html.py HtmlExporter(Document AST → 完整 HTML5)
|
||||
│ ├── pdf.py PdfExporter(Document AST → PDF,reportlab)
|
||||
│ └── docx.py DocxExporter(Document AST → DOCX,python-docx)
|
||||
└── service.py ExportService(注册表 + 后台渲染 + 取消 + 产物生命周期)
|
||||
|
||||
backend/app/plot/
|
||||
├── parser.py 函数图像表达式解析(白名单 AST)
|
||||
├── render.py FunctionPlot → 静态 SVG
|
||||
└── renderer.py StaticRenderer 内部契约(§10.4)
|
||||
```
|
||||
|
||||
HTTP DTO(`ExportStatus` / `ExportFormat` / `ExportSource` / `ExportOptions` / `ExportJob` 等)放在 [app/contracts.py](../../backend/app/contracts.py),与 Benchmark DTO 同层;`DocumentNode` / `ExportResult` 属导出器内部协议,放在 `export/document.py`,不进入 HTTP 契约。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/exports` | 创建导出任务(202) |
|
||||
| GET | `/api/exports?status=&format=&limit=&offset=` | 分页获取任务 |
|
||||
| GET | `/api/exports/{job_id}` | 查询任务状态 |
|
||||
| GET | `/api/exports/{job_id}/file` | 下载已完成产物 |
|
||||
| POST | `/api/exports/{job_id}/cancel` | 取消任务 |
|
||||
|
||||
`source.type` 支持 `note`(引用已建索引笔记)与 `markdown`(未保存预览,字段为 `source.markdown`,上限 200 000 字符)。`format` 支持 `html` / `pdf` / `docx` 三种,经 `service._EXPORTERS` 注册表按格式分发到对应导出器。
|
||||
|
||||
## Markdown → Document AST
|
||||
|
||||
解析用 [mistune](https://github.com/lepture/mistune) 的内置 `renderer="ast"`(非自写 `BaseRenderer`),因为 mistune 的行内渲染按字符串拼接、无法承载结构化子节点;ast renderer 直接给出带 `children`/`attrs`/`raw` 的 token 树,`_AstMapper` 只做 token → `DocumentNode` 的搬运,不掺入任何 HTML。插件启用 `table`、`math`、`url`、`task_lists`。
|
||||
|
||||
fenced code 按语言分流:`mermaid` → `mermaid` 节点、`function_plot`/`functionplot` → `function_plot` 节点,其余 → `code_block`(`attributes.language`)。`node_id` 按遍历顺序 `node_{seq:03d}` 生成,仅渲染内部使用,无需跨请求稳定。
|
||||
|
||||
## HtmlExporter
|
||||
|
||||
递归渲染 Document AST 为完整 HTML5 文档(`<!doctype html>` + `<head>` 内嵌基础 CSS + `<body>`),标题/正文/元信息文本一律 `html.escape`。`function_plot` 经 `FunctionPlotStaticRenderer` 解析为静态 SVG 内嵌(解析/渲染失败或超限时回退 `<pre class="function-plot">` 占位并记 warning),`mermaid` 无法静态表达,渲染为占位 `<pre class="mermaid">` 并记 warning,均不静默丢失;`code_theme` 仅作为代码容器 class,不引入 JS 高亮库。无法表示的节点统一 `warnings.append(...)` 跳过。
|
||||
|
||||
## StaticRenderer 内部契约(§10.4)
|
||||
|
||||
函数图像与 Mermaid 的静态渲染统一收敛到 `app/plot/renderer.py`:
|
||||
|
||||
- `StaticRenderRequest`(`kind` / `source` / `source_hash` / `theme` / `width` / `height`)是统一的渲染请求载体,`source_hash` 供缓存/去重,`theme` 供主题化渲染。
|
||||
- `StaticRenderer` Protocol 定义 `render(request) -> StaticRenderResult`,导出器只面向协议,不直接调用 `render_svg`。
|
||||
- `FunctionPlotStaticRenderer` 委托 `parse_source` 解析 + `render_svg` 输出内嵌 SVG;`parse` 与 `render_plot` 拆开,供导出器在渲染前先拿 `node_count` 做文档级累计复杂度预算。
|
||||
- `MermaidStaticRenderer` 后端无 Mermaid 渲染能力,返回空占位结果并记 warning,交由前端渲染。
|
||||
|
||||
## PDF / DOCX 导出器(v1 文本优先)
|
||||
|
||||
`PdfExporter`(reportlab platypus)与 `DocxExporter`(python-docx)实现与 HtmlExporter 一致的同步 `render(document, options) -> ExportResult` + 异步 `export`。v1 为文本优先,覆盖标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本;`function_plot` 与 `mermaid` 保留源码占位并记 warning(与现有 Mermaid 处理一致)。
|
||||
|
||||
- PDF 中文字体用 reportlab 内置 `STSong-Light` CID 字体,无外部字体依赖;CID 字体无独立 bold/italic 字重,行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
||||
- DOCX 通过 Normal 样式挂载 `w:eastAsia=宋体` 保证中文显示,bold/italic 由 Word 原生渲染;链接写入可点击的 `w:hyperlink` run。
|
||||
- 扩展名/MIME:html→`.html`/`text/html`,pdf→`.pdf`/`application/pdf`,docx→`.docx`/`application/vnd.openxmlformats-officedocument.wordprocessingml.document`;路由 `FileResponse` 按 `mime_type` + `file_name` 通用化,无需改路由。
|
||||
|
||||
## 运行生命周期
|
||||
|
||||
`queued → running → completed | failed | cancelled`。
|
||||
|
||||
- 创建时校验:`note` 源不存在 → `EXPORT_SOURCE_NOT_FOUND`(404);`markdown` 源为空或超上限 → `EXPORT_OPTIONS_INVALID`。
|
||||
- 内存注册表上限 `MAX_JOBS=100`,超限只淘汰终态任务;满容量且全为活动任务时返回 `EXPORT_CAPACITY_EXCEEDED`(429)。
|
||||
- 后台渲染在解析前后各让出一次执行权,使「创建后立即取消」的 queued 任务能及时进入 cancelled。
|
||||
- 失败只向公开响应暴露项目错误码与安全消息,详细异常进入日志。
|
||||
|
||||
## 产物生命周期
|
||||
|
||||
产物写入 `settings.exports_path`(默认 `backend/data/exports/`,可通过 `APP_EXPORTS_PATH` 覆盖,已加入 `.gitignore`),文件名为 `{job_id}{ext}`(`ext` 由格式决定),下载 `Content-Disposition` 用 `_safe_download_name` 清洗标题得到。`ExportFile` 记录 `sha256`、`size` 与 `expires_at`(`completed_at + 24h`),过期返回 `EXPORT_FILE_EXPIRED`(410)。
|
||||
|
||||
## 资源上限
|
||||
|
||||
为防止超大输入或海量函数图像耗尽内存/线程,导出链路内置以下上限:
|
||||
|
||||
- 输入源(`note` 与 `markdown`)统一限制 `MAX_MARKDOWN_CHARS = 200_000` 字符,超限返回 `EXPORT_OPTIONS_INVALID`。
|
||||
- 单个 `function-plot` 图块最多 16 条表达式,超限整块回退占位并记结构化诊断 `FUNCTION_PLOT_TOO_MANY_EXPRESSIONS`。
|
||||
- 单篇文档最多 16 个函数图像,超出部分回退占位并记 warning。
|
||||
- 单篇文档累计函数图像 AST 节点预算 `_MAX_TOTAL_PLOT_NODES = 8000`,超出部分回退占位并记 warning,防止多图块 × 多表达式 × 深表达式组合在采样求值时长时间占满 CPU。
|
||||
- 并发渲染上限 `MAX_CONCURRENT_RENDERS = 2`,解析/渲染是 CPU 密集工作,超出限额的任务在内存中排队等待渲染槽位,避免大量任务同时占满工作线程与内存。
|
||||
- 最终产物大小上限 `MAX_EXPORT_BYTES = 20 MB`,超限任务标记 failed 并返回 `EXPORT_OUTPUT_TOO_LARGE`。
|
||||
|
||||
## 错误码
|
||||
|
||||
错误分两类:**同步错误**在创建/查询请求的 HTTP 响应里直接返回对应状态码;**异步任务错误**在创建时已返回 `202`,后续轮询 `GET /api/exports/{job_id}` 仍返回 `200`,错误通过任务状态与 `error_code` 字段暴露,**不映射 HTTP 状态码**。
|
||||
|
||||
同步错误:
|
||||
|
||||
```text
|
||||
EXPORT_SOURCE_NOT_FOUND 404
|
||||
EXPORT_OPTIONS_INVALID 400
|
||||
EXPORT_UNSUPPORTED_CONTENT 422(预留)
|
||||
EXPORT_JOB_NOT_FOUND 404
|
||||
EXPORT_FILE_EXPIRED 410
|
||||
EXPORT_CAPACITY_EXCEEDED 429
|
||||
```
|
||||
|
||||
异步任务错误(轮询返回 `200`,字段形如 `{"status": "failed", "error_code": "..."}`):
|
||||
|
||||
```text
|
||||
EXPORT_RENDER_FAILED
|
||||
EXPORT_OUTPUT_TOO_LARGE
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、PDF/DOCX 魔法字节与 CJK 字体、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。`tests/test_plot.py` 覆盖表达式解析/求值、SVG 渲染与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
|
||||
|
||||
## 范围外(后续 PR)
|
||||
|
||||
- PDF 内嵌函数图像与 Mermaid 渲染(v1 仅源码占位)。
|
||||
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
|
||||
- 代码语法高亮(当前仅 CSS class 占位)。
|
||||
@@ -1,28 +1,5 @@
|
||||
# 主题组件覆盖检查(2026-09-05)
|
||||
|
||||
## 2026-09-06:Markdown 行为与文档滚动适配
|
||||
|
||||
| 主题 | 当前版本 |
|
||||
| --- | --- |
|
||||
| Light / Dark / Sepia | 1.4.0 |
|
||||
| 纸间时光 | 1.9.0 |
|
||||
| Ocean Blue | 1.6.0 |
|
||||
| Midnight Purple | 2.4.0 |
|
||||
|
||||
新增共享 `frontend/src/styles/markdown-behavior.css`,由应用入口及隔离主题预览同时加载。六个主题通过语义变量提供链接悬停与键盘焦点、任务复选框、引用与分隔线、文字选区、表格选区、标题折叠状态、警告框折叠状态和文档滚动按钮配色。标题箭头沿用悬停或键盘聚焦时显示的规则;表格选区采用透明叠色,避免遮住单元格文字。纸间时光保留长文轻量背景实现。
|
||||
|
||||
只读 Markdown(包括 AI 对话)现在同步代码行号、自动换行和缩进宽度设置。语法是否启用仍由 Markdown 预设与解析器决定;ATX / Setext 等源码写法解析成同级标题后使用相同主题样式,不用 CSS 模拟语法。主题预览新增 H4–H6、嵌套列表、任务列表、链接、行内代码、折叠警告框、带行号长代码及滚动按钮样例。
|
||||
|
||||
工作区滚动按钮固定在编辑区域右下角,适用于写作和源码模式:顶部只显示到底部、底部只显示到顶部、中间同时显示;空文档或没有可滚动内容时隐藏。滚动状态通过被动监听与动画帧合并更新,尺寸、内容和折叠变化会重新计算,减少动画偏好开启时直接跳转。
|
||||
|
||||
验证方法:
|
||||
|
||||
1. 运行 `cd frontend` 后执行 `npm test` 和 `npm run build`,检查主题变量覆盖、六主题预览矩阵、主题升级、Markdown 预设和滚动按钮回归。
|
||||
2. 本地启动前端,访问 `tests/visual/index.html?theme=light`,依次切换表中六个主题,检查新增样例的链接焦点、复选框、折叠态、代码换行及行号。社区主题已安装旧版本时,应在主题页更新后再检查。
|
||||
3. 工作区分别打开空笔记、短笔记、长笔记,切换写作与源码模式,在顶部、中间、底部验证按钮数量和跳转方向;调整窗口尺寸、折叠标题后再次检查。
|
||||
|
||||
本次自动检查覆盖仓库内六主题的结构和样式变量,不包含用户自行导入的任意第三方 CSS,也不等同于所有浏览器的逐页视觉验收。
|
||||
|
||||
本次检查仓库内 3 个内置主题和 3 个社区预设,共扫描 105 个前端源文件的组件与语义样式变量。用户自行导入的第三方 CSS 不在仓库中,不据此声称已验收。
|
||||
|
||||
## 范围与结果
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
theme_id: paper-moments
|
||||
name: 纸间时光 · Paper Moments
|
||||
version: 1.9.0
|
||||
version: 1.8.1
|
||||
author: NotesAgent
|
||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||
min_app_version: 0.2.0
|
||||
@@ -9,9 +9,6 @@ css_entry: theme.css
|
||||
license: MIT
|
||||
---
|
||||
[data-theme="paper-moments"] {
|
||||
--color-markdown-selection: #ecd3c7;
|
||||
--color-editor-scroll-background: #fff9e9;
|
||||
--color-editor-scroll-text: #875343;
|
||||
--color-callout-info: #406b7b;
|
||||
--color-callout-success: #536f43;
|
||||
--color-callout-warning: #875f25;
|
||||
|
||||
@@ -24,7 +24,7 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" :data-code-wrap="markdownPreferences.normalized.wrapCode" :data-line-numbers="markdownPreferences.normalized.lineNumbers" :style="{ '--markdown-code-indent': markdownPreferences.normalized.indent }" v-html="html" /></DiagramInteractions>
|
||||
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" v-html="html" /></DiagramInteractions>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -36,10 +36,12 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () =>
|
||||
.markdown-content li::marker { color: var(--color-markdown-marker); font-weight: 700; }
|
||||
.markdown-content .shiki { overflow: auto; margin: .85em 0; padding: 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background) !important; color: var(--color-code-text); font-family: var(--font-ui-mono); font-size: .875em; line-height: 1.45; tab-size: 4; }
|
||||
.markdown-content code { padding: .1em .3em; border-radius: var(--radius-sm); background: var(--color-background-tertiary); font-family: var(--font-ui-mono); }
|
||||
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
|
||||
.markdown-content :not(pre) > code { background: var(--color-code-background); color: var(--color-code-text); border: 1px solid var(--color-code-border); }
|
||||
.markdown-content div.markdown-math { overflow-x: auto; padding-block: .5em; }
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 { margin: 1em 0 .5em; font-weight: 600; }
|
||||
.markdown-content input[type="checkbox"] { margin-right: .45em; accent-color: var(--color-accent-primary); }
|
||||
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
|
||||
.markdown-content blockquote { padding-left: 1em; border-left: 3px solid var(--color-accent-primary); color: var(--color-text-secondary); }
|
||||
.markdown-content table { width: 100%; margin: .65em 0; border-collapse: collapse; }
|
||||
.markdown-content th, .markdown-content td { padding: .45em .65em; border: 1px solid var(--color-markdown-grid); text-align: left; }
|
||||
|
||||
@@ -3,14 +3,12 @@ import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import EditorScrollButtons from './EditorScrollButtons.vue'
|
||||
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
watch(() => editorStore.headingRequest, request => {
|
||||
const input = sourceEditor.value
|
||||
if (!request || !input || request.path !== editorStore.currentFilePath) return
|
||||
@@ -26,17 +24,13 @@ function updateContent(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="editor-scroll-pane">
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||
<EditorScrollButtons :container="container" :content="editorStore.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-scroll-pane { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.editor-pane {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EditorScrollButtons from './EditorScrollButtons.vue'
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
it('shows only useful directions, scrolls the editor, and hides empty or short documents', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }))
|
||||
const container = document.createElement('div')
|
||||
const viewport = document.createElement('textarea')
|
||||
viewport.className = 'source'
|
||||
container.append(viewport)
|
||||
document.body.append(container)
|
||||
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
const scroll = vi.fn()
|
||||
viewport.scrollTo = scroll
|
||||
const wrapper = mount(EditorScrollButtons, { props: { container, content: 'A long note' } })
|
||||
const update = async (top: number) => {
|
||||
viewport.scrollTop = top
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
}
|
||||
try {
|
||||
await update(0)
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
expect(wrapper.find('button').attributes('aria-label')).toBe('滑动到底部')
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(scroll).toHaveBeenLastCalledWith({ top: 1000, behavior: 'smooth' })
|
||||
await update(400)
|
||||
expect(wrapper.findAll('button')).toHaveLength(2)
|
||||
await update(800)
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
expect(wrapper.find('button').attributes('aria-label')).toBe('滑动到顶部')
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(scroll).toHaveBeenLastCalledWith({ top: 0, behavior: 'smooth' })
|
||||
await wrapper.setProps({ content: ' \n' })
|
||||
await update(400)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
Object.defineProperty(viewport, 'scrollHeight', { value: 200 })
|
||||
await wrapper.setProps({ content: 'Short note' })
|
||||
await update(0)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Top, Bottom } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const props = defineProps<{ container: HTMLElement | null; content: string }>()
|
||||
const hasContent = computed(() => !!props.content.trim())
|
||||
const canUp = ref(false), canDown = ref(false)
|
||||
let viewport: HTMLElement | null = null
|
||||
let frame = 0
|
||||
let mutation: MutationObserver | undefined
|
||||
let resize: ResizeObserver | undefined
|
||||
function update() {
|
||||
frame = 0
|
||||
const next = props.container?.querySelector<HTMLElement>('.milkdown-host, textarea.source') ?? null
|
||||
if (next !== viewport) {
|
||||
viewport?.removeEventListener('scroll', schedule)
|
||||
resize?.disconnect()
|
||||
viewport = next
|
||||
viewport?.addEventListener('scroll', schedule, { passive: true })
|
||||
}
|
||||
if (viewport) {
|
||||
resize?.observe(viewport)
|
||||
for (const child of viewport.children) resize?.observe(child)
|
||||
const document = viewport.querySelector('.ProseMirror')
|
||||
if (document) resize?.observe(document)
|
||||
}
|
||||
const max = viewport ? viewport.scrollHeight - viewport.clientHeight : 0
|
||||
const visible = hasContent.value && max > 2
|
||||
canUp.value = visible && viewport!.scrollTop > 2
|
||||
canDown.value = visible && viewport!.scrollTop < max - 2
|
||||
}
|
||||
function schedule() { if (!frame) frame = requestAnimationFrame(update) }
|
||||
function scroll(to: 'top' | 'bottom') {
|
||||
viewport?.scrollTo({ top: to === 'top' ? 0 : viewport.scrollHeight,
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
||||
}
|
||||
watch(() => props.container, container => {
|
||||
mutation?.disconnect()
|
||||
resize?.disconnect()
|
||||
mutation = new MutationObserver(schedule)
|
||||
resize = new ResizeObserver(schedule)
|
||||
if (container) mutation.observe(container, { childList: true, subtree: true, characterData: true, attributes: true })
|
||||
schedule()
|
||||
}, { immediate: true, flush: 'post' })
|
||||
watch(() => props.content, schedule)
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(frame)
|
||||
mutation?.disconnect()
|
||||
resize?.disconnect()
|
||||
viewport?.removeEventListener('scroll', schedule)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="canUp || canDown" class="editor-scroll-buttons" :aria-label="t('文档滚动', 'Document scrolling')">
|
||||
<button v-if="canUp" type="button" class="button-secondary" :title="t('滑动到顶部', 'Scroll to top')" :aria-label="t('滑动到顶部', 'Scroll to top')" @click="scroll('top')"><AppIcon :icon="Top" :size="20" /></button>
|
||||
<button v-if="canDown" type="button" class="button-secondary" :title="t('滑动到底部', 'Scroll to bottom')" :aria-label="t('滑动到底部', 'Scroll to bottom')" @click="scroll('bottom')"><AppIcon :icon="Bottom" :size="20" /></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,6 @@ vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node
|
||||
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
|
||||
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
|
||||
import CommunityThemePreview from './CommunityThemePreview.vue'
|
||||
vi.mock('@/styles/markdown-behavior.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/markdown-behavior.css', 'utf8') }))
|
||||
import { mockCommunityThemes } from '@/services/themePackageService'
|
||||
const themes = [...['light','dark','sepia'].map(theme_id => ({theme_id,name:theme_id,builtin:true})), ...mockCommunityThemes.map(t => ({...t,builtin:false}))]
|
||||
it.each(themes)('previews shared component states safely for $theme_id', theme => {
|
||||
@@ -17,9 +16,6 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html')
|
||||
expect(doc.documentElement.dataset.theme).toBe(theme.theme_id)
|
||||
expect(doc.querySelector('script')).toBeNull()
|
||||
for (const selector of ['.editor-scroll-buttons button', '.markdown-content h6', '.task-list-item input:checked', '.markdown-content[data-code-wrap="true"][data-line-numbers="true"] .line', '.heading-fold-toggle']) expect(doc.querySelector(selector), selector).not.toBeNull()
|
||||
expect(doc.querySelector('style')!.textContent).toContain('.editor-scroll-buttons button:focus-visible')
|
||||
expect(doc.querySelector('style')!.textContent).toContain(".markdown-content[data-code-wrap='true'] .shiki code")
|
||||
expect(doc.querySelectorAll('.specimen-callouts > aside.markdown-callout')).toHaveLength(14)
|
||||
expect(doc.querySelector('.specimen-callouts > details[open]')).not.toBeNull()
|
||||
expect(doc.querySelector('.specimen-callouts > details:not([open])')).not.toBeNull()
|
||||
@@ -29,12 +25,6 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
|
||||
expect(doc.querySelector('style')!.textContent).not.toContain('color:white')
|
||||
const rules = Array.from(doc.styleSheets[0]!.cssRules) as CSSStyleRule[]
|
||||
// The sandbox cannot inherit MarkdownContent's component stylesheet.
|
||||
const codeRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki code')!
|
||||
const lineRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki .line')!
|
||||
expect(codeRule.style.getPropertyValue('display')).toBe('block')
|
||||
expect(lineRule.style.getPropertyValue('display')).toBe('block')
|
||||
expect(lineRule.style.getPropertyValue('min-height')).toBe('1.45em')
|
||||
const rootRule = rules.filter(rule => rule.selectorText === 'html').pop()!
|
||||
const bodyRule = rules.filter(rule => rule.selectorText === 'body').pop()!
|
||||
// The embedded document must override the app-shell overflow lock.
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/the
|
||||
import tokensCss from '@/styles/tokens.css?raw'
|
||||
import featuresCss from '@/styles/features.css?raw'
|
||||
import headingsCss from '@/styles/headings.css?raw'
|
||||
import markdownBehaviorCss from '@/styles/markdown-behavior.css?raw'
|
||||
import calloutsCss from '@/styles/callouts.css?raw'
|
||||
import { calloutTypes } from '@/utils/callouts'
|
||||
import specimenHtml from './themeSpecimen.html?raw'
|
||||
@@ -26,7 +25,6 @@ const previewDocument = computed(() => {
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${headingsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
||||
|
||||
style.textContent += `\n${markdownBehaviorCss}\n.specimen-scroll { position:relative; min-height:110px; }`
|
||||
doc.head.append(style)
|
||||
const article = doc.createElement('article')
|
||||
article.className = 'panel'
|
||||
|
||||
@@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them
|
||||
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
|
||||
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.9.0')
|
||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.8.1')
|
||||
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
|
||||
})
|
||||
|
||||
@@ -20,20 +20,3 @@ console.log(note);</code></pre><table><thead><tr><th>名称</th><th>状态</th><
|
||||
|
||||
<section class="surface-nested markdown-preferences"><h2>Markdown 语法预设</h2><label>标题样式 <select class="select"><option>ATX (#)</option><option>Setext</option></select></label><label><input type="checkbox" checked> 警告框与提示框</label><button class="button-secondary">保存为预设</button></section>
|
||||
<section class="milkdown"><div class="ProseMirror"><h2><button class="heading-fold-toggle" aria-expanded="true" aria-label="折叠示例标题"></button>悬停查看折叠箭头</h2><p>折叠按钮跟随主题,键盘聚焦时也可见。</p></div></section>
|
||||
<section class="markdown-content specimen-markdown">
|
||||
<h2>Markdown 行为与状态</h2>
|
||||
<h4>四级标题</h4><h5>五级标题</h5><h6>六级标题</h6>
|
||||
<p><a href="#markdown-preview-target">链接:检查悬停与键盘焦点</a>,<code>行内代码</code>与<strong>强调</strong>、<em>斜体</em>、<del>删除线</del>。选中这段文字检查主题选区颜色。</p>
|
||||
<ul><li>无序列表<ul><li>嵌套列表</li></ul></li></ul><ol><li>有序列表</li><li>第二项</li></ol>
|
||||
<ul><li class="task-list-item"><input type="checkbox" disabled> 未完成任务</li><li class="task-list-item"><input type="checkbox" checked disabled> 已完成任务</li></ul>
|
||||
<blockquote><p>普通引用</p><blockquote>嵌套引用</blockquote></blockquote><hr>
|
||||
<details class="markdown-callout" data-callout="tip"><summary class="callout-title">点击展开提示</summary><div class="callout-body"><p>提示内的 <code>代码</code> 与列表也继承主题。</p></div></details>
|
||||
<p id="markdown-preview-target">ATX 和 Setext 解析后使用相同标题样式;关闭扩展语法时保留源码展示。</p>
|
||||
<pre><code>> [!NOTE]
|
||||
关闭警告框扩展时显示原始内容
|
||||
```mermaid
|
||||
graph LR; A-->B
|
||||
```</code></pre>
|
||||
</section>
|
||||
<section class="surface-nested specimen-scroll"><p>工作区滚动按钮:悬停、按下或 Tab 聚焦检查配色。</p><div class="editor-scroll-buttons"><button class="button-secondary" aria-label="滑动到顶部" title="滑动到顶部">↑</button><button class="button-secondary" aria-label="滑动到底部" title="滑动到底部">↓</button></div></section>
|
||||
<section class="markdown-content specimen-markdown" data-code-wrap="true" data-line-numbers="true"><h3>代码换行与行号</h3><pre class="shiki"><code><span class="line">const description = "这是一段用于检查代码自动换行、行号与主题配色的较长说明,较窄视口下也应该保留可读内容。";</span><span class="line">console.log(description);</span></code></pre></section>
|
||||
|
||||
@@ -4,7 +4,6 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/features.css'
|
||||
import './styles/markdown-behavior.css'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { useSettingsStore } from './stores/settings'
|
||||
import { watch } from 'vue'
|
||||
|
||||
@@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{
|
||||
theme_id: 'ocean-blue',
|
||||
name: 'Ocean Blue',
|
||||
version: '1.6.0',
|
||||
version: '1.5.0',
|
||||
author: 'community',
|
||||
description: '宁静的海洋蓝色主题,适合长时间阅读',
|
||||
min_app_version: '0.2.0',
|
||||
@@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{
|
||||
theme_id: 'midnight-purple',
|
||||
name: 'Midnight Purple',
|
||||
version: '2.4.0',
|
||||
version: '2.3.0',
|
||||
author: 'night-owl',
|
||||
description: '深紫色暗夜主题,适合编码',
|
||||
min_app_version: '0.2.0',
|
||||
@@ -464,9 +464,6 @@ export function getCommunityThemePreviewCss(themeId: string): string {
|
||||
return buildCommunityThemeCss(themeId, t.is_dark) + `
|
||||
[data-theme="${themeId}"] {
|
||||
color-scheme: ${t.is_dark ? 'dark' : 'light'};
|
||||
--color-markdown-selection: ${t.is_dark ? '#443252' : '#d4eaf5'};
|
||||
--color-editor-scroll-background: var(--color-surface-elevated);
|
||||
--color-editor-scroll-text: var(--color-accent-primary);
|
||||
--color-callout-info: ${t.is_dark ? '#9dbbff' : '#126589'};
|
||||
--color-callout-success: ${t.is_dark ? '#a7d58c' : '#267049'};
|
||||
--color-callout-warning: ${t.is_dark ? '#efc886' : '#885c13'};
|
||||
|
||||
@@ -6,9 +6,9 @@ import * as themePkg from '@/services/themePackageService'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const builtinThemes = (): ThemeConfig[] => [
|
||||
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.4.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.4.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.4.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.3.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.3.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.3.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
]
|
||||
|
||||
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/* Shared interaction layer: colors follow the active theme; syntax preferences
|
||||
continue to be handled by the parser/editor rather than CSS-generated content. */
|
||||
:is(.markdown-content, .milkdown .ProseMirror) a:not(.callout-title) {
|
||||
color: var(--color-text-link); text-decoration: underline; text-underline-offset: .18em;
|
||||
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
|
||||
}
|
||||
:is(.markdown-content, .milkdown .ProseMirror) a:hover { text-decoration-color: currentColor; }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) a:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) input[type='checkbox'] { accent-color: var(--color-accent-primary); }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) input[type='checkbox']:disabled { opacity: 1; }
|
||||
.markdown-content .task-list-item { list-style: none; }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) blockquote:not(.markdown-callout) { border-inline-start: 3px solid var(--color-markdown-marker); color: var(--color-text-secondary); }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) hr { border: 0; border-top: 1px solid var(--color-markdown-grid); }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) :is(h1,h2,h3,h4,h5,h6) { scroll-margin-top: 16px; }
|
||||
:is(.markdown-content, .milkdown .ProseMirror) :is(p,li,h1,h2,h3,h4,h5,h6,code)::selection,
|
||||
.editor-pane.source::selection { background: var(--color-markdown-selection); color: var(--color-text-primary); }
|
||||
.milkdown .ProseMirror .selectedCell::after { background: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); pointer-events: none; }
|
||||
.milkdown .ProseMirror .heading-fold-toggle[aria-expanded='false'] { color: var(--color-accent-primary); background: var(--color-accent-soft); }
|
||||
.markdown-callout[data-collapsed='true'] { border-style: dashed; border-inline-start-style: solid; }
|
||||
.markdown-content details.markdown-callout:not([open]) { border-style: dashed; border-inline-start-style: solid; }
|
||||
.editor-pane.source { caret-color: var(--color-accent-primary); }
|
||||
.markdown-content .shiki code { display: block; min-width: max-content; padding: 0; background: transparent; font: inherit; }
|
||||
.markdown-content .shiki .line { display: block; min-height: 1.45em; }
|
||||
.markdown-content[data-code-wrap] .shiki { tab-size: var(--markdown-code-indent, 4); }
|
||||
.markdown-content[data-code-wrap='true'] .shiki code { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.markdown-content[data-line-numbers='true'] .shiki code { counter-reset: code-line; }
|
||||
.markdown-content[data-line-numbers='true'] .shiki .line { position: relative; padding-inline-start: 3.5em; }
|
||||
.markdown-content[data-line-numbers='true'] .shiki .line::before { counter-increment: code-line; content: counter(code-line); position: absolute; inset-inline-start: 0; width: 2.5em; text-align: end; color: var(--color-code-muted); user-select: none; }
|
||||
:is(.milkdown-host, .editor-pane.source) { scrollbar-color: var(--color-border-default) var(--color-background-primary); }
|
||||
.editor-scroll-buttons { position: absolute; right: 24px; bottom: 24px; z-index: 10; display: flex; flex-direction: column; gap: 8px; }
|
||||
.editor-scroll-buttons button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; min-height: 38px; padding: 0; border-radius: var(--radius-md); background: var(--color-editor-scroll-background); color: var(--color-editor-scroll-text); border: 1px solid var(--color-border-default); box-shadow: var(--shadow-sm); }
|
||||
.editor-scroll-buttons button:hover { background: var(--color-background-hover); border-color: var(--color-accent-primary); }
|
||||
.editor-scroll-buttons button:active { background: var(--color-accent-soft); }
|
||||
.editor-scroll-buttons button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
||||
@@ -1,7 +1,4 @@
|
||||
:root {
|
||||
--color-markdown-selection: color-mix(in srgb, var(--color-accent-primary) 24%, var(--color-background-primary));
|
||||
--color-editor-scroll-background: var(--color-surface-elevated);
|
||||
--color-editor-scroll-text: var(--color-accent-primary);
|
||||
/* Callout heading colors also serve as borders; keep text readable on tint. */
|
||||
--color-callout-info: var(--color-info);
|
||||
--color-callout-success: var(--color-success);
|
||||
|
||||
Reference in New Issue
Block a user