diff --git a/README.md b/README.md
index 80cab60..167d4e3 100644
--- a/README.md
+++ b/README.md
@@ -39,22 +39,22 @@
| [SeqViz](workbenches/52-seqviz/) | 序列导入、注释审阅、环形/线性视图与导出 | 序列审阅与注释轨道 |
| [farmOS-map](workbenches/53-farmos-map/) | 地块绘制、边界调整、备注与 GeoJSON 导出 | 地图与地块记录 |
| [Mirador](workbenches/54-mirador/) | 本地图像、双页对照、逐页复核与报告导出 | 双图光台与复核账本 |
-| [OpenSheetMusicDisplay](workbenches/55-opensheetmusicdisplay/) | MusicXML/MXL 导入、谱面校审、批注及原件/SVG 导出 | 乐谱纸张与边注 |
-| [LangExtract](workbenches/56-langextract/) | 原文、JSONL证据定位、字段复核、人工修订与CSV/JSONL/项目导出 | 原文纸张与证据账本 |
-| [Docling](workbenches/57-docling/) | DoclingDocument结构树、文本/表格校订、顺序、JSON/Markdown/CSV与HTML连接器 | 结构轨、校样纸张与表格检查器 |
+| [OpenSheetMusicDisplay](workbenches/55-opensheetmusicdisplay/) | MusicXML/MXL 原件库、光标与回放、移调与声部切换、谱面批注联动、PDF/PNG/SVG 导出 | 乐谱库、原生谱面与联动批注账本 |
+| [LangExtract](workbenches/56-langextract/) | 多文档原文标注、类别与示例配方、区间对齐、审阅统计、JSONL/CSV/可视化 HTML 导出 | 原文纸张与证据账本、档案菜单 |
+| [Docling](workbenches/57-docling/) | 本地 PDF/DOCX/HTML/Markdown 转换、原页与结构块框选、文本表格校订、Docling JSON/Markdown/HTML/CSV 导出 | 原页、结构导航与校订检查器 |
| [LangGraph.js](workbenches/58-langgraph/) | Gemini 3.8资料整理、真实StateGraph、人工复核、SQLite检查点与交付导出 | 三站复核流程与运行账本 |
-| [Promptfoo](workbenches/59-promptfoo/) | 真实评测结果矩阵、基线、人工复核、限定套件执行器与CSV/JSON导出 | 用例×版本矩阵与结果对照 |
-| [faster-whisper](workbenches/60-faster-whisper/) | 音频保存/回听、真实CPU转写、字幕时间编辑与SRT/VTT/完整项目导出 | 暗色波形、字幕行与任务队列 |
-| [Presenton](workbenches/61-presenton/) | 资料、大纲、页序、备注、审阅、便携稿与异步制作服务连接器 | 逐页缩略轨、演示舞台与资料简报 |
+| [Promptfoo](workbenches/59-promptfoo/) | 用例×提示版本×provider 评测矩阵、本机回放与脚本、常用断言、运行对比、发布判断、配置和结果导入导出 | 评测矩阵、基线差异与断言详情 |
+| [faster-whisper](workbenches/60-faster-whisper/) | 示例音频与逐词转写、波形入出点、拆分合并、查找替换、说话人标注、SRT/VTT/TXT/JSON 导出 | 波形时间轴、字幕表与上下文检查器 |
+| [Presenton](workbenches/61-presenton/) | 资料导入、大纲编辑、本地幻灯片生成、11 种版式与 5 套主题、原生图表 PPTX 与 PDF 导出 | 资料—大纲—成稿步骤、缩略轨与幻灯片舞台 |
| [Penpot](workbenches/44-penpot/) | 颜色、字体、组件样本与交付说明,原生Penpot归档及CSS导出 | 设计资产样本册与色彩、字体分页 |
| [HyperFrames](workbenches/62-hyperframes/) | 镜头文字与构图、时间线、HyperFrames HTML和真实MP4渲染 | 暗色动效舞台、镜头轨与输出队列 |
-| [Graphify](workbenches/63-graphify/) | 真实AST抽取、原生图谱、关系路径、布局与审阅笔记 | 知识地图、符号目录与关系检查器 |
+| [Graphify](workbenches/63-graphify/) | 多文件与文件夹知识抽取、Louvain 社区、力导向图、原文定位、搜索路径、GraphML/交互 HTML/完整项目导出 | 中央知识地图、社区资料轨与原文检查器 |
| [PageIndex](workbenches/64-pageindex/) | 浏览器 PDF.js 阅读(无需服务)、章节树、字符级摘录/引用高亮、检索与 Host 原页问答 | 章节目录、PDF 原页与审阅问答 |
| [DeepTutor](workbenches/65-deeptutor/) | 离线判题/排程/题库导入、错题解析与 Host 导师、可控出题与间隔复习 | 对页练习本与题号状态条 |
| [Agent-Reach](workbenches/66-agent-reach/) | 内置 GitHub/网页/搜索/V2EX 采集、首开示例调研、原文摘录高亮、可选 Host 概括与报告 | 来源目录、原文阅读与摘录夹 |
-| [OpenMontage](workbenches/67-openmontage/) | 上传原片、入出点、镜头复核、上游组接和MP4交付 | 分镜制作单、原片预览与成片架 |
-| [DeerFlow](workbenches/68-deerflow/) | 研究提交、原生线程运行查询、来源复核和报告交付 | 研究进度、交付纸张与来源证据页 |
-| [MiroFish](workbenches/69-mirofish/) | 已准备模拟的启动/停止、状态核对、原生动作与人工复核 | 角色目录、轮次时间线与动作复核 |
+| [OpenMontage](workbenches/67-openmontage/) | 多轨视频图片音频字幕、移动修剪分割、转场混音、浏览器 MP4/WebM 成片与完整工程导出 | 影片菜单、监视器、多轨时间线与检查器 |
+| [DeerFlow](workbenches/68-deerflow/) | 问题与可确认计划、本地检索、引用联动、来源核验、报告编辑、Markdown/HTML 导出及可选 SSE 研究服务 | 计划、执行、报告阶段与来源证据栏 |
+| [MiroFish](workbenches/69-mirofish/) | 资料与实体图谱、人设编辑、可复现规则推演、事件注入、立场时间线、报告与完整推演文件导出 | 六阶段推演工作区、立场图表与结果时间线 |
| [GEOInflow](workbenches/70-geoinflow/) | 日志与内容清单、原生UA/引荐分类、覆盖分析和审阅导出 | 宽幅访问账本、范围筛选与逐页调查 |
| [OpenPencil](workbenches/71-openpencil/) | 图层与属性、原生SceneGraph、FIG/SVG及完整项目导出 | 全幅组件画板、图层与属性工具 |
| [FigCraft](workbenches/72-figcraft/) | 无需引擎的近似校样、文案/图层/填色/交付状态写回、SVG/PNG/交付说明导出;原生 PNG/PDF 可选 | 画板胶片、校样舞台与审阅检查器 |
@@ -88,6 +88,8 @@ X选题补充:[9个项目与转化优先级](docs/research/x-project-shortlist
AI 补充调研:[32 个 AI 项目与工作台选型](docs/research/ai-workbench-candidates.md)。覆盖文档抽取、知识图谱、Agent、评测、字幕、配音、课件和视觉生成,另含 3 个通用组件;已列明接口、独立服务、模型许可和维护边界。该次首批完成6项;后续转化状态以当前清单为准,正式模型/外部服务范围以各项目说明为准。
+第3波10项已完成本地功能验收与打包,纳入本地整合分支。第4–6波30项待开展。详细范围、验证与限制见[第三波记录](docs/release-0.9.0.md#第3波--本地工作流与可选服务)。
+
## 工作台实景
以下为 57 个已验收本地工作台的真实 Chrome 界面,使用普通示例资料。点击预览进入项目说明;图片未经过界面合成或像素修饰。采集版本、原始尺寸与哈希见 [截图说明](docs/screenshots/README.md)。
diff --git a/catalog.json b/catalog.json
index 62cf2d6..9027dca 100644
--- a/catalog.json
+++ b/catalog.json
@@ -2573,40 +2573,42 @@
"kind": "行业核心库集成",
"workbench": "MusicXML/MXL → 声部/版式审阅 → 乐谱图与批注清单",
"license": "BSD-3-Clause",
- "caveat": "是渲染器;完整制谱和播放器不计入首版",
+ "caveat": "基础合成音回放未做听音验收;大文件用生成乐谱验证;PDF 为栅格校样;客户端导入未验证。",
"effort": "中",
"priority": "A",
"directory": "workbenches/55-opensheetmusicdisplay",
"status": "local-verified",
- "checked_at": "2026-10-27",
+ "checked_at": "2026-10-29",
"researchId": "V27",
"sources": [
"https://github.com/opensheetmusicdisplay/opensheetmusicdisplay",
"docs/research/niche-creative.md"
],
- "version": "0.3.1",
+ "version": "0.9.0",
"verification": {
"build": "passed",
- "tests": 12,
+ "tests": 17,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
- "screenshot": "README.md#工作台实景",
- "hostLlm": "not-retested",
+ "productLayout": "pending",
+ "screenshot": "docs/screenshots/opensheetmusicdisplay.png",
+ "hostLlm": "not-used",
"hostImport": "unverified",
- "verifiedAt": "2026-10-27",
- "record": "docs/release-0.3.0.md",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
"package": "passed",
"sharedAutosaveRevert": "passed",
"sharedClientSha256": "1663f8bb8ffa9a79d016180b7e98ee229b02683e246767f5146c9e1eac50312d",
- "browserScope": "沿用 0.2.0 业务界面验收;0.3.0 共同保存代码经独立 Chrome/SQLite 回归,全部重新构建/测试/打包。",
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。",
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "pending",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md",
+ "basis": "product flow and screenshots reviewed; formal cross-workbench review pending"
+ },
+ "upstream": "OSMD 2.2.1 原生渲染、光标、移调与声部控制;大谱与导出本地浏览器验证",
+ "acceptance": "workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md"
},
"presentation": {
"screenshots": [
@@ -2615,7 +2617,10 @@
"caption": "OpenSheetMusicDisplay 工作台实景"
}
]
- }
+ },
+ "core_features": "MusicXML/MXL 原件库、光标与回放、移调与声部切换、谱面批注联动、PDF/PNG/SVG 导出",
+ "integration": "浏览器 OSMD 2.2.1 与 Web Audio;无需模型或外部服务",
+ "mode": "本地乐谱审校"
},
{
"name": "LangExtract",
@@ -2624,21 +2629,21 @@
"category": "AI 工作台",
"kind": "服务适配",
"workbench": "证据字段抽取与人工复核台",
- "core_features": "原文、JSONL证据定位、字段复核、人工修订与CSV/JSONL/项目导出",
- "integration": "Python 核心+自建任务服务;Apache-2.0",
+ "core_features": "多文档原文标注、类别与示例配方、区间对齐、审阅统计、JSONL/CSV/可视化 HTML 导出",
+ "integration": "Node 区间对齐与可选 Floatboat Host 抽取;Python 服务可选",
"effort": "中–高",
"priority": "A",
"license": "Apache-2.0",
- "caveat": "真实模型抽取、Host与客户端导入未验证。",
+ "caveat": "Host 模型抽取仅 mock 验证;Python 服务未接真实模型;正式批次上限 880 KB;客户端导入未验证。",
"sources": [
"https://github.com/google/langextract"
],
"group": "AI 工具",
- "mode": "独立服务与结果审阅",
- "checked_at": "2026-10-27",
+ "mode": "本地证据标注与可选模型抽取",
+ "checked_at": "2026-10-29",
"verification": {
"build": "passed",
- "tests": 17,
+ "tests": 34,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
@@ -2646,23 +2651,24 @@
"package": "passed",
"screenshot": "docs/screenshots/langextract.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-27",
- "record": "docs/release-0.4.0.md",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
"acceptance": "workbenches/56-langextract/ACCEPTANCE.md",
- "upstream": "真实LangExtract库+明确stub提供者;真实模型未验证",
- "hostLlm": "not-used",
+ "upstream": "LangExtract 式 few-shot 提示、JSONL 与原文对齐;Host mock 合同及本地浏览器验证",
+ "hostLlm": "mock-verified",
"structuralLayout": {
"status": "pending",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source scan only; visual/task review pending"
- }
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/56-langextract/ACCEPTANCE.md",
+ "basis": "product flow and screenshots reviewed; formal cross-workbench review pending"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"id": 56,
"slug": "langextract",
"directory": "workbenches/56-langextract",
"status": "local-verified",
- "version": "0.4.1",
+ "version": "0.9.0",
"presentation": {
"screenshots": [
{
@@ -2679,45 +2685,46 @@
"category": "AI 工作台",
"kind": "服务适配",
"workbench": "文档结构、文本与表格校对台",
- "core_features": "DoclingDocument结构树、文本/表格校订、顺序、JSON/Markdown/CSV与HTML连接器",
- "integration": "官方 docling-serve;代码 MIT,模型单列",
+ "core_features": "本地 PDF/DOCX/HTML/Markdown 转换、原页与结构块框选、文本表格校订、Docling JSON/Markdown/HTML/CSV 导出",
+ "integration": "浏览器 PDF.js 与 Node 文档解析;Docling Serve 可选",
"effort": "中–高",
"priority": "A",
"license": "MIT",
- "caveat": "当前上传仅HTML;无PDF/OCR/DOCX预览或转换;官方Serve live与客户端导入未验证。",
+ "caveat": "本地解析不含 OCR 或官方版面模型;Docling Serve 用本机夹具验证,真实服务、Host 与客户端导入未验证。",
"sources": [
"https://github.com/docling-project/docling"
],
"group": "AI 工具",
- "mode": "独立服务与结果审阅",
- "checked_at": "2026-10-27",
+ "mode": "原页对照与结构校订",
+ "checked_at": "2026-10-29",
"verification": {
"build": "passed",
- "tests": 23,
+ "tests": 35,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "pending",
+ "productLayout": "passed",
"package": "passed",
"screenshot": "docs/screenshots/docling.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-27",
- "record": "docs/release-0.4.0.md",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
"acceptance": "workbenches/57-docling/app/ACCEPTANCE.md",
- "upstream": "真实Docling core HTML转换与原生JSON回读;官方Serve live未验证",
+ "upstream": "DoclingDocument 原生 schema 与 Markdown 导出兼容检查;Serve 为 HTTP 合同夹具",
"hostLlm": "not-used",
"structuralLayout": {
- "status": "pending",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source scan only; visual/task review pending"
- }
+ "status": "passed",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/57-docling/app/ACCEPTANCE.md",
+ "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"id": 57,
"slug": "docling",
"directory": "workbenches/57-docling",
"status": "local-verified",
- "version": "0.4.1",
+ "version": "0.9.0",
"presentation": {
"screenshots": [
{
@@ -2791,45 +2798,46 @@
"category": "AI 工作台",
"kind": "服务适配",
"workbench": "提示词与模型回归评测台",
- "core_features": "真实评测结果矩阵、基线、人工复核、限定套件执行器与CSV/JSON导出",
- "integration": "外部 runner/真实结果导入;MIT",
+ "core_features": "用例×提示版本×provider 评测矩阵、本机回放与脚本、常用断言、运行对比、发布判断、配置和结果导入导出",
+ "integration": "Node 本机评测与可选 Floatboat Host;官方 Promptfoo CLI 可选",
"effort": "中–高",
"priority": "A",
"license": "MIT",
- "caveat": "限定v3结果与安全JSON套件;执行器独立部署,外部模型与客户端导入未验证。",
+ "caveat": "本机执行器为确定性子集;真实 Host/LLM 与客户端导入未验证;官方执行器仅回显/本机数据验收。",
"sources": [
"https://github.com/promptfoo/promptfoo"
],
"group": "AI 工具",
- "mode": "独立服务与结果审阅",
- "checked_at": "2026-10-27",
+ "mode": "多版本评测与发布复核",
+ "checked_at": "2026-10-29",
"verification": {
"build": "passed",
- "tests": 11,
+ "tests": 25,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
+ "productLayout": "passed",
"package": "passed",
"screenshot": "docs/screenshots/promptfoo.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-27",
- "record": "docs/release-0.4.0.md",
- "acceptance": "workbenches/59-promptfoo/ACCEPTANCE.md",
- "upstream": "真实Promptfoo evaluator回显评测;外部模型未验证",
- "hostLlm": "not-used",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
+ "acceptance": "workbenches/59-promptfoo/app/ACCEPTANCE.md",
+ "upstream": "Promptfoo 官方执行器 216 格确定性结果对照、3 项执行器测试;Host 为 mock",
+ "hostLlm": "mock-verified",
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "passed",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/59-promptfoo/app/ACCEPTANCE.md",
+ "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"id": 59,
"slug": "promptfoo",
"directory": "workbenches/59-promptfoo",
"status": "local-verified",
- "version": "0.4.1",
+ "version": "0.9.0",
"presentation": {
"screenshots": [
{
@@ -2846,45 +2854,46 @@
"category": "AI 工作台",
"kind": "服务适配",
"workbench": "音频转录与字幕审校台",
- "core_features": "音频保存/回听、真实CPU转写、字幕时间编辑与SRT/VTT/完整项目导出",
- "integration": "自建 Python 任务服务,CPU 可起步;MIT",
+ "core_features": "示例音频与逐词转写、波形入出点、拆分合并、查找替换、说话人标注、SRT/VTT/TXT/JSON 导出",
+ "integration": "浏览器字幕校对;本机 faster-whisper Python 服务提供转写",
"effort": "中–高",
"priority": "A",
"license": "MIT",
- "caveat": "真实推理仅验证tiny.en英语短录音CPU路线;Python/模型独立部署,其他语种/GPU/客户端导入未验证。",
+ "caveat": "新音频自动转写仍需 Python 服务;small 中文 CPU 已有实测记录,翻译质量、GPU、长音频与客户端导入未验证。",
"sources": [
"https://github.com/SYSTRAN/faster-whisper"
],
"group": "AI 工具",
- "mode": "独立服务与结果审阅",
- "checked_at": "2026-10-27",
+ "mode": "本地字幕校对与可选语音转写",
+ "checked_at": "2026-10-29",
"verification": {
"build": "passed",
- "tests": 15,
+ "tests": 30,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
+ "productLayout": "pending",
"package": "passed",
"screenshot": "docs/screenshots/faster-whisper.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-27",
- "record": "docs/release-0.4.0.md",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
"acceptance": "workbenches/60-faster-whisper/ACCEPTANCE.md",
- "upstream": "真实tiny.en CPU INT8英语转写,主仓库完整链路复验",
+ "upstream": "已有 small 中文 CPU 真实转写与 tiny.en 英文记录;本轮复跑 30 项应用测试,未重复模型推理",
"hostLlm": "not-used",
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "pending",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/60-faster-whisper/ACCEPTANCE.md",
+ "basis": "product flow and screenshots reviewed; formal cross-workbench review pending"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"id": 60,
"slug": "faster-whisper",
"directory": "workbenches/60-faster-whisper",
"status": "local-verified",
- "version": "0.4.1",
+ "version": "0.9.0",
"presentation": {
"screenshots": [
{
@@ -2901,21 +2910,21 @@
"category": "AI 工作台",
"kind": "服务适配",
"workbench": "资料、大纲与 AI 课件制作台",
- "core_features": "资料、大纲、页序、备注、审阅、便携稿与异步制作服务连接器",
- "integration": "外部 Docker/self-host API;Apache-2.0",
+ "core_features": "资料导入、大纲编辑、本地幻灯片生成、11 种版式与 5 套主题、原生图表 PPTX 与 PDF 导出",
+ "integration": "本地资料生成与 PptxGenJS 导出;Floatboat Host/Presenton 服务可选",
"effort": "中–高",
"priority": "A",
"license": "Apache-2.0",
- "caveat": "实际生成及PPTX/PDF递送需要配置Presenton服务,尚未live验证;客户端导入未验证。",
+ "caveat": "PDF 为栅格页;PowerPoint 内打开、真实 Host/Presenton 服务及客户端导入未验证;本地生成按规则整理原文。",
"sources": [
"https://github.com/presenton/presenton"
],
"group": "AI 工具",
- "mode": "独立服务与结果审阅",
- "checked_at": "2026-10-27",
+ "mode": "资料到大纲与演示成稿",
+ "checked_at": "2026-10-29",
"verification": {
"build": "passed",
- "tests": 29,
+ "tests": 49,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
@@ -2923,23 +2932,24 @@
"package": "passed",
"screenshot": "docs/screenshots/presenton.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-27",
- "record": "docs/release-0.4.0.md",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
"acceptance": "workbenches/61-presenton/app/ACCEPTANCE.md",
- "upstream": "本地编辑/导出与官方API合同;真实生成和上游文件递送未验证",
- "hostLlm": "not-used",
+ "upstream": "PptxGenJS 原生图表导出、PDF 与导出结构验证;Host mock,真实 Presenton 服务未验证",
+ "hostLlm": "mock-verified",
"structuralLayout": {
"status": "pending",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source scan only; visual/task review pending"
- }
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/61-presenton/app/ACCEPTANCE.md",
+ "basis": "product flow and screenshots reviewed; formal cross-workbench review pending"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"id": 61,
"slug": "presenton",
"directory": "workbenches/61-presenton",
"status": "local-verified",
- "version": "0.4.1",
+ "version": "0.9.0",
"presentation": {
"screenshots": [
{
@@ -3013,44 +3023,45 @@
"category": "AI 工作台",
"kind": "引擎/应用",
"workbench": "代码与资料知识地图",
- "core_features": "真实AST抽取、原生图谱、关系路径、布局与审阅笔记",
- "integration": "独立Python服务,默认8781",
+ "core_features": "多文件与文件夹知识抽取、Louvain 社区、力导向图、原文定位、搜索路径、GraphML/交互 HTML/完整项目导出",
+ "integration": "Node 内置抽取与内容地址库;Python AST/Floatboat Host 可选",
"effort": "中–高",
"priority": "A",
"license": "Apache-2.0",
- "caveat": "真实graphifyy 0.9.97 AST提取;语义LLM提取未启用;Floatboat客户端导入未验证。",
+ "caveat": "Node 抽取为确定性启发式,复杂语义不保证;Host 仅 mock;大图极限、真实 Python HTTP 到 UI 及客户端导入未验证。",
"sources": [
"https://github.com/Graphify-Labs/graphify"
],
"group": "AI 工具",
- "mode": "引擎封装",
- "checked_at": "2026-10-28",
+ "mode": "资料抽取与知识地图审阅",
+ "checked_at": "2026-10-29",
"slug": "graphify",
"directory": "workbenches/63-graphify",
"status": "local-verified",
- "version": "0.5.1",
+ "version": "0.9.0",
"verification": {
"build": "passed",
- "tests": 12,
+ "tests": 26,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
+ "productLayout": "passed",
"package": "passed",
"screenshot": "docs/screenshots/graphify.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-28",
- "record": "docs/release-0.5.0.md",
- "acceptance": "workbenches/63-graphify/ACCEPTANCE.md",
- "upstream": "真实graphifyy 0.9.97 AST提取;语义LLM提取未启用",
- "hostLlm": "not-used",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
+ "acceptance": "workbenches/63-graphify/app/ACCEPTANCE.md",
+ "upstream": "Python graphifyy 0.9.97 真实库测试;Node 与独立 Chrome 多文件抽取、社区及导出验证",
+ "hostLlm": "mock-verified",
"serviceTests": 4,
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "passed",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/63-graphify/app/ACCEPTANCE.md",
+ "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"presentation": {
"screenshots": [
@@ -3240,44 +3251,45 @@
"category": "AI 工作台",
"kind": "引擎/应用",
"workbench": "视频脚本、分镜、素材与制作审批",
- "core_features": "上传原片、入出点、镜头复核、上游组接和MP4交付",
- "integration": "独立Python/FFmpeg制作服务,默认8797",
+ "core_features": "多轨视频图片音频字幕、移动修剪分割、转场混音、浏览器 MP4/WebM 成片与完整工程导出",
+ "integration": "浏览器本地合成与分块素材库;外部 OpenMontage 渲染可选",
"effort": "中–高",
"priority": "A",
"license": "AGPL-3.0",
- "caveat": "真实OpenMontage裁切/组接;未启用AI视频生成;Floatboat客户端导入未验证。",
+ "caveat": "长片、大素材、4K 和其他编码器组合未验证;可选 OpenMontage 服务未重测;Host、客户端导入未验证。",
"sources": [
"https://github.com/calesthio/OpenMontage"
],
"group": "AI 工具",
- "mode": "引擎封装",
- "checked_at": "2026-10-28",
+ "mode": "多轨剪辑与本地成片",
+ "checked_at": "2026-10-29",
"slug": "openmontage",
"directory": "workbenches/67-openmontage",
"status": "local-verified",
- "version": "0.5.1",
+ "version": "0.9.0",
"verification": {
"build": "passed",
- "tests": 9,
+ "tests": 25,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
+ "productLayout": "passed",
"package": "passed",
"screenshot": "docs/screenshots/openmontage.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-28",
- "record": "docs/release-0.5.0.md",
- "acceptance": "workbenches/67-openmontage/ACCEPTANCE.md",
- "upstream": "真实OpenMontage裁切/组接;未启用AI视频生成",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
+ "acceptance": "workbenches/67-openmontage/app/ACCEPTANCE.md",
+ "upstream": "独立 Chrome 实际编码并以 ffprobe 核对桌面/375px H.264+AAC 成片;可选服务仅合同测试",
"hostLlm": "not-used",
"serviceTests": 4,
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "passed",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/67-openmontage/app/ACCEPTANCE.md",
+ "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"presentation": {
"screenshots": [
@@ -3296,44 +3308,45 @@
"category": "AI 工作台",
"kind": "引擎/应用",
"workbench": "研究计划、证据与报告交付",
- "core_features": "研究提交、原生线程运行查询、来源复核和报告交付",
- "integration": "需配置官方DeerFlow服务与模型;默认请求gemini-3.8-flash",
+ "core_features": "问题与可确认计划、本地检索、引用联动、来源核验、报告编辑、Markdown/HTML 导出及可选 SSE 研究服务",
+ "integration": "Node 检索与本地研究底稿;Floatboat Host/DeerFlow SSE 可选",
"effort": "中–高",
"priority": "A",
"license": "MIT",
- "caveat": "真实官方序列化器与请求模型;运行HTTP契约通过,真实研究服务未验证;Floatboat客户端导入未验证。",
+ "caveat": "真实 DeerFlow、外网检索与 Host 未测;服务与检索用本机夹具,Host 用 mock;客户端导入未验证。",
"sources": [
"https://github.com/bytedance/deer-flow"
],
"group": "AI 工具",
- "mode": "引擎封装",
- "checked_at": "2026-10-28",
+ "mode": "计划确认、研究执行与引用报告",
+ "checked_at": "2026-10-29",
"slug": "deerflow",
"directory": "workbenches/68-deerflow",
"status": "local-verified",
- "version": "0.5.1",
+ "version": "0.9.0",
"verification": {
"build": "passed",
- "tests": 19,
+ "tests": 53,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
+ "productLayout": "pending",
"package": "passed",
"screenshot": "docs/screenshots/deerflow.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-28",
- "record": "docs/release-0.5.0.md",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
"acceptance": "workbenches/68-deerflow/ACCEPTANCE.md",
- "upstream": "真实官方序列化器与请求模型;运行HTTP契约通过,真实研究服务未验证",
- "hostLlm": "not-used",
+ "upstream": "本机检索夹具、DeerFlow SSE 断线续接与请求合同;真实研究服务未验证",
+ "hostLlm": "mock-verified",
"serviceTests": 0,
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "pending",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/68-deerflow/ACCEPTANCE.md",
+ "basis": "product flow and screenshots reviewed; formal cross-workbench review pending"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"presentation": {
"screenshots": [
@@ -3352,44 +3365,45 @@
"category": "AI 工作台",
"kind": "引擎/应用",
"workbench": "角色、假设与情景推演实验",
- "core_features": "已准备模拟的启动/停止、状态核对、原生动作与人工复核",
- "integration": "需配置官方MiroFish服务,预先准备模拟、模型和Zep",
+ "core_features": "资料与实体图谱、人设编辑、可复现规则推演、事件注入、立场时间线、报告与完整推演文件导出",
+ "integration": "Node 本地规则推演;Floatboat Host 与 MiroFish/OASIS/Zep 服务可选",
"effort": "中–高",
"priority": "A",
"license": "AGPL-3.0",
- "caveat": "原生动作格式与运行HTTP契约通过;真实OASIS/Zep模拟未验证;Floatboat客户端导入未验证。",
+ "caveat": "本地规则模拟不等于模型预测;保留 900 KB 模型容量限制,PDF 无 OCR;真实 Host/OASIS/Zep 与客户端导入未验证。",
"sources": [
"https://github.com/666ghj/MiroFish"
],
"group": "AI 工具",
- "mode": "引擎封装",
- "checked_at": "2026-10-28",
+ "mode": "资料、图谱、人设、推演与结果复核",
+ "checked_at": "2026-10-29",
"slug": "mirofish",
"directory": "workbenches/69-mirofish",
"status": "local-verified",
- "version": "0.5.1",
+ "version": "0.9.0",
"verification": {
"build": "passed",
- "tests": 14,
+ "tests": 35,
"browser": "passed",
"autosave": "passed",
"workingDraftRecovery": "passed",
- "productLayout": "needs-redesign",
+ "productLayout": "passed",
"package": "passed",
"screenshot": "docs/screenshots/mirofish.png",
"hostImport": "unverified",
- "verifiedAt": "2026-10-28",
- "record": "docs/release-0.5.0.md",
- "acceptance": "workbenches/69-mirofish/ACCEPTANCE.md",
- "upstream": "原生动作格式与运行HTTP契约通过;真实OASIS/Zep模拟未验证",
- "hostLlm": "not-used",
+ "verifiedAt": "2026-10-29",
+ "record": "docs/release-0.9.0.md",
+ "acceptance": "workbenches/69-mirofish/app/ACCEPTANCE.md",
+ "upstream": "本地确定性推演、导出回读与 Host mock 验证;真实 OASIS/Zep 未运行",
+ "hostLlm": "mock-verified",
"serviceTests": 0,
"structuralLayout": {
- "status": "needs-redesign",
- "reviewedAt": "2026-10-28",
- "record": "docs/layout-audit-2026-10-28.md",
- "basis": "source header cluster; task redesign queued"
- }
+ "status": "passed",
+ "reviewedAt": "2026-10-29",
+ "record": "workbenches/69-mirofish/app/ACCEPTANCE.md",
+ "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots"
+ },
+ "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。"
},
"presentation": {
"screenshots": [
diff --git a/docs/release-0.9.0.md b/docs/release-0.9.0.md
index 379c707..d8edfe2 100644
--- a/docs/release-0.9.0.md
+++ b/docs/release-0.9.0.md
@@ -78,6 +78,31 @@
| Oasis Editor |
|
|
| CircuitJS |
|
|
+## 第3波 · 本地工作流与可选服务
+
+第3波10项已完成本地功能验收与打包,纳入本地整合分支。第4–6波的30项尚未开展。本地验收与商店上架、客户端导入分别记录。
+
+| 工作台 | 0.9.0 能力 | 应用测试 | 验收记录 |
+| --- | --- | ---: | --- |
+| OpenSheetMusicDisplay | MusicXML/MXL 原件库、光标与回放、移调与声部切换、谱面批注联动、PDF/PNG/SVG 导出 | 17 | [验收](../workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md) |
+| LangExtract | 多文档原文标注、类别与示例配方、区间对齐、审阅统计、JSONL/CSV/可视化 HTML 导出 | 34 | [验收](../workbenches/56-langextract/ACCEPTANCE.md) |
+| Docling | 本地 PDF/DOCX/HTML/Markdown 转换、原页与结构块框选、文本表格校订、Docling JSON/Markdown/HTML/CSV 导出 | 35 | [验收](../workbenches/57-docling/app/ACCEPTANCE.md) |
+| Promptfoo | 用例×提示版本×provider 评测矩阵、本机回放与脚本、常用断言、运行对比、发布判断、配置和结果导入导出 | 25 | [验收](../workbenches/59-promptfoo/app/ACCEPTANCE.md) |
+| faster-whisper | 示例音频与逐词转写、波形入出点、拆分合并、查找替换、说话人标注、SRT/VTT/TXT/JSON 导出 | 30 | [验收](../workbenches/60-faster-whisper/ACCEPTANCE.md) |
+| Presenton | 资料导入、大纲编辑、本地幻灯片生成、11 种版式与 5 套主题、原生图表 PPTX 与 PDF 导出 | 49 | [验收](../workbenches/61-presenton/app/ACCEPTANCE.md) |
+| Graphify | 多文件与文件夹知识抽取、Louvain 社区、力导向图、原文定位、搜索路径、GraphML/交互 HTML/完整项目导出 | 26 | [验收](../workbenches/63-graphify/app/ACCEPTANCE.md) |
+| OpenMontage | 多轨视频图片音频字幕、移动修剪分割、转场混音、浏览器 MP4/WebM 成片与完整工程导出 | 25 | [验收](../workbenches/67-openmontage/app/ACCEPTANCE.md) |
+| DeerFlow | 问题与可确认计划、本地检索、引用联动、来源核验、报告编辑、Markdown/HTML 导出及可选 SSE 研究服务 | 53 | [验收](../workbenches/68-deerflow/ACCEPTANCE.md) |
+| MiroFish | 资料与实体图谱、人设编辑、可复现规则推演、事件注入、立场时间线、报告与完整推演文件导出 | 35 | [验收](../workbenches/69-mirofish/app/ACCEPTANCE.md) |
+
+本轮共 **329 项应用测试**通过,全部已接收项的 check、package 通过。最初五项由整合者独立重跑 check/test;剩余项由接手实现者在各自工作树完成检查与独立浏览器验收。整合按项目逐一比对最终提交,未重复计算解包后重跑或服务测试。
+
+1440/375 浏览器主流程、自动保存和失败处理详见逐项记录。最初五项的浏览器运行来自本日原验收记录,整合者复核最终截图,未将历史运行记为本次重跑;后五项进行了本次独立浏览器验收。Docling、Graphify 另在解包副本运行测试。OpenMontage 实际导出桌面与窄屏成片,以 ffprobe 核对编码、尺寸和音轨。功能验收与结构对照分别记录,部分项目的正式跨工作台结构审查仍为 pending。
+
+安装包逐文件核对源码和最终应用文档。OSMD 清理文件尾部空行后重新打包;DeerFlow 原包携带旧版应用说明,已在最终文档完成后重新打包并更新哈希。manifest 资源路径按模板规则规范化。未推送、未合并 main、未发布商店。
+
+真实 Floatboat Host/LLM 未调用,模型通道仅使用 mock;faster-whisper 的 small 中文 CPU 实测沿用本日原验收记录。外部服务范围以逐项验收为准。共享自动保存的约900KB项目上限仍存在;部分大文件通过独立内容库保存,不能把输入上限等同完整项目可保存容量。
+
## 新增 · ComfyUI(#79)
基于 MIT 的 `@comfyorg/litegraph` 0.17.2 节点画布,经 ComfyUI 官方 HTTP/WebSocket API 连接用户自有服务;工作台为 MIT,不含 ComfyUI 或 ComfyUI_frontend 的 GPL 代码与模型权重。
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
index 9185a3c..179c63a 100644
--- a/docs/screenshots/README.md
+++ b/docs/screenshots/README.md
@@ -2,7 +2,7 @@
真实Chrome运行界面,使用普通示例资料。图片为浏览器原生PNG,未合成界面或修改像素。最近采集日期:2026-10-29。
-0.9.0 第1波更新8项、第2波更新8项、新增 ComfyUI;其余保留历史采集版本。ComfyUI 印样带中的图片来自模拟服务,不是真实生成结果。示例、真实引擎输出、真实Host回答和服务未连接状态按各项目验收记录区分。
+0.9.0 第1波更新8项、第2波更新8项、第3波更新10项、新增 ComfyUI;其余保留历史采集版本。ComfyUI 印样带中的图片来自模拟服务,不是真实生成结果。示例、真实引擎输出、真实Host回答和服务未连接状态按各项目验收记录区分。
| 图片 | 采集版本 | 原始尺寸 | 大小 | SHA-256 |
| --- | --- | --- | --- | --- |
@@ -31,22 +31,22 @@
| [seqviz.png](seqviz.png) | 0.2.0 | 1440 × 980 | 145.9 KiB | `d0dd49f1c4f92a298caa7107ced737694cbd8b3cb87000b07c221258733b2ea1` |
| [farmos-map.png](farmos-map.png) | 0.2.0 | 1440 × 1000 | 129.5 KiB | `740a90faad06eb6aa22fcbebcfb3e3ad11ac84cddf40dac61f2ad5d0fd076814` |
| [mirador.png](mirador.png) | 0.2.0 | 1440 × 1000 | 123.4 KiB | `24efe96f869e1be32cb1f43e47eb4902ca5d008f52ace32350d8db25c54360dc` |
-| [opensheetmusicdisplay.png](opensheetmusicdisplay.png) | 0.2.0 | 1440 × 1000 | 81.0 KiB | `05fb14de1ef33ba47f9138729959899a21a01cd053a737a8ad2b64a7a4c9f2d1` |
-| [langextract.png](langextract.png) | 0.4.0 | 1440 × 1243 | 160.8 KiB | `a5d4eef95117713c1fc8ce3f656659b4bf747e180e5fe7348cff8ef0dc19397d` |
-| [docling.png](docling.png) | 0.4.0 | 1440 × 1000 | 128.4 KiB | `9991beb70e9285d26fc7e1108b53ef01c83593a3e474c9bc372a0f37d655ce7c` |
+| [opensheetmusicdisplay.png](opensheetmusicdisplay.png) | 0.9.0 | 1440 × 900 | 207.2 KiB | `3b537ba8976f0b8678a0bc4a4015dbf155fd075a017c38eda25f0cc599483334` |
+| [langextract.png](langextract.png) | 0.9.0 | 1440 × 1100 | 240.6 KiB | `b35e0bc51a88619ddce1b31780b729606cb7b74645e0c5eadb73066e080de33f` |
+| [docling.png](docling.png) | 0.9.0 | 1440 × 900 | 328.0 KiB | `0cf3e95c25115c6d7a2ee6bf05ce106918d6dc7e0cd558afda7a3a520c767f10` |
| [langgraph.png](langgraph.png) | 0.4.1 | 1440 × 1313 | 188.4 KiB | `686185f8275b53d40f075cb7c8f68e91f79a12476f0e2ee4da30cba675e212ea` |
-| [promptfoo.png](promptfoo.png) | 0.4.0 | 1440 × 1185 | 105.7 KiB | `5ab982dd8f1ab9830671fbf3ecfbcf76a5b5de9a99608e0c4b20da812134eb65` |
-| [faster-whisper.png](faster-whisper.png) | 0.4.0 | 1440 × 1392 | 145.7 KiB | `f662dd5659d280b5555fcb9a61abaefca01ac07541fccbcbf23bfa5195628d32` |
-| [presenton.png](presenton.png) | 0.4.0 | 1440 × 1206 | 185.5 KiB | `c26564d38a34c6620396e5d576d5e3ad0c098ab230597e1d7879003a4c1e70c3` |
+| [promptfoo.png](promptfoo.png) | 0.9.0 | 1440 × 1000 | 295.3 KiB | `dc2fcbba1ade9bbd25f3c36b60f52f0fc3db4cbbec4d377c83d48608d316de1c` |
+| [faster-whisper.png](faster-whisper.png) | 0.9.0 | 1440 × 960 | 246.1 KiB | `7786c80d85e2f9ae90cf1550fc24ebcdfdc02744e3cf8cb6c4f0abc84ae5ad2d` |
+| [presenton.png](presenton.png) | 0.9.0 | 1440 × 900 | 196.7 KiB | `1b0652eaf58a68e667277168f7efdae9767dc6c9fabdc8497f869af921b41c21` |
| [penpot.png](penpot.png) | 0.5.0 | 1440 × 1000 | 72.0 KiB | `6fa3f9dfe034acc01869d8f4cd8b40b057ee3e2f454ae52bba161db7b9c79412` |
| [hyperframes.png](hyperframes.png) | 0.5.0 | 1440 × 1149 | 128.0 KiB | `c24389c51798d7e300f9bf2d37625f31c691628ddadf15705c2e7912f62258a4` |
-| [graphify.png](graphify.png) | 0.5.0 | 1440 × 1100 | 115.8 KiB | `d75280a48fe240d3e3187471d2670897a25630726ca7c16b95bfb1c9b2975ee1` |
+| [graphify.png](graphify.png) | 0.9.0 | 1440 × 900 | 325.8 KiB | `1e0cee859346773b5d3ebc7c55a12318b60070a1ecfcac334e607cd86455c6a4` |
| [pageindex.png](pageindex.png) | 0.9.0 | 1440 × 900 | 240.6 KiB | `ec0ec8c1d009e8cff2ee2fa79786d26c89773932dac1fea1c093f12582f141b3` |
| [deeptutor.png](deeptutor.png) | 0.9.0 | 1440 × 900 | 239.2 KiB | `359a289a06965c0dd2358cc514c3be731c81195bc3573cff89c8d65212ecc568` |
| [agent-reach.png](agent-reach.png) | 0.9.0 | 1440 × 900 | 321.3 KiB | `a363ba029896dc2de954a34b6ce89a8e751ef0211b2b8618355f3cde282e2326` |
-| [openmontage.png](openmontage.png) | 0.5.0 | 1440 × 1100 | 122.7 KiB | `5a11fac21433b61856c62cdb9707fe5e08be448d4d83669376aa534489050bbe` |
-| [deerflow.png](deerflow.png) | 0.5.0 | 1440 × 1337 | 155.1 KiB | `afe240962bb7b23a5cf75ce632210058acb93c9d4b001036cfdee1b568955e69` |
-| [mirofish.png](mirofish.png) | 0.5.0 | 1440 × 1393 | 225.9 KiB | `e09aeb3bc89814586761cfc908366a7c98f501e092cd919b5552856ba58b0b32` |
+| [openmontage.png](openmontage.png) | 0.9.0 | 1440 × 900 | 286.0 KiB | `0b6a061503e92f755c0a965df7afd2d9a3c58d82d3d931cb5f000e10579a3ca5` |
+| [deerflow.png](deerflow.png) | 0.9.0 | 1440 × 900 | 394.8 KiB | `e2025ecf9f43b5c8012b9073e96b0eec1306da490f0158411fc4fb609da96af8` |
+| [mirofish.png](mirofish.png) | 0.9.0 | 1440 × 1000 | 148.6 KiB | `f7afa30872f63885bf89577ae53a1813bb6fdbaca6bfb58f748bdd3fa68186f6` |
| [geoinflow.png](geoinflow.png) | 0.5.0 | 1440 × 1941 | 176.0 KiB | `64f321a6fed1bfd363a50f9cfd80eed256bbf6474f2e99e1eba0415c2d5d9ce7` |
| [openpencil.png](openpencil.png) | 0.5.0 | 1440 × 1000 | 108.4 KiB | `4d4471197e9fee67c626b9a80cfe34f6ae57b3dc824c662c416b7937ed9e51bc` |
| [figcraft.png](figcraft.png) | 0.9.0 | 1440 × 900 | 326.2 KiB | `8b2b7f2ec622b0a536cf905451594274f6c8b9f4fcaa4874deaa024ed9d442fb` |
@@ -64,4 +64,4 @@
| [gltf-transform.png](gltf-transform.png) | 0.9.0 | 1440 × 900 | 143.7 KiB | `e1f5b5a1a8520c4204e8acc28e4f420043e4941dd329a27feeaf9a3f9fdd5aed` |
| [comfyui.png](comfyui.png) | 0.9.0 | 1440 × 900 | 153.9 KiB | `d3ba181c4efe1c4f97aa3dc4c9604c9514be39d093a040f2bb84b41dffddeb58` |
-共57张,合计 8.95 MiB。
+共57张,合计 10.16 MiB。
diff --git a/docs/screenshots/deerflow.png b/docs/screenshots/deerflow.png
index c882760..64cf1db 100644
Binary files a/docs/screenshots/deerflow.png and b/docs/screenshots/deerflow.png differ
diff --git a/docs/screenshots/docling.png b/docs/screenshots/docling.png
index 71481c4..66fc529 100644
Binary files a/docs/screenshots/docling.png and b/docs/screenshots/docling.png differ
diff --git a/docs/screenshots/faster-whisper.png b/docs/screenshots/faster-whisper.png
index 0252dc6..97be604 100644
Binary files a/docs/screenshots/faster-whisper.png and b/docs/screenshots/faster-whisper.png differ
diff --git a/docs/screenshots/graphify.png b/docs/screenshots/graphify.png
index baaf890..7e8e64f 100644
Binary files a/docs/screenshots/graphify.png and b/docs/screenshots/graphify.png differ
diff --git a/docs/screenshots/langextract.png b/docs/screenshots/langextract.png
index 515311b..dc730cd 100644
Binary files a/docs/screenshots/langextract.png and b/docs/screenshots/langextract.png differ
diff --git a/docs/screenshots/mirofish.png b/docs/screenshots/mirofish.png
index a2ba196..87e0dec 100644
Binary files a/docs/screenshots/mirofish.png and b/docs/screenshots/mirofish.png differ
diff --git a/docs/screenshots/openmontage.png b/docs/screenshots/openmontage.png
index 06c352d..bbdacc0 100644
Binary files a/docs/screenshots/openmontage.png and b/docs/screenshots/openmontage.png differ
diff --git a/docs/screenshots/opensheetmusicdisplay.png b/docs/screenshots/opensheetmusicdisplay.png
index ebc4d7a..62f2733 100644
Binary files a/docs/screenshots/opensheetmusicdisplay.png and b/docs/screenshots/opensheetmusicdisplay.png differ
diff --git a/docs/screenshots/presenton.png b/docs/screenshots/presenton.png
index 6cd57ed..d1433ba 100644
Binary files a/docs/screenshots/presenton.png and b/docs/screenshots/presenton.png differ
diff --git a/docs/screenshots/promptfoo.png b/docs/screenshots/promptfoo.png
index 918100a..1db260d 100644
Binary files a/docs/screenshots/promptfoo.png and b/docs/screenshots/promptfoo.png differ
diff --git a/workbenches/55-opensheetmusicdisplay/NOTICE.md b/workbenches/55-opensheetmusicdisplay/NOTICE.md
index 29164ee..4fd5d72 100644
--- a/workbenches/55-opensheetmusicdisplay/NOTICE.md
+++ b/workbenches/55-opensheetmusicdisplay/NOTICE.md
@@ -1,9 +1,14 @@
# 第三方许可与内容来源
-- [OpenSheetMusicDisplay](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay) **2.2.1**,BSD-3-Clause;使用 npm 官方发布浏览器构建,原始许可保留在 app/src/vendor/OSMD-LICENSE。[官方入门与 load/render API](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/wiki/Getting-started)、[源项目许可](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/blob/develop/LICENSE)。
+- [OpenSheetMusicDisplay](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay) **2.2.1**,BSD-3-Clause;使用 npm 官方发布浏览器构建(含 Cursor、TransposeCalculator),原始许可保留在 app/src/vendor/OSMD-LICENSE。[官方入门与 load/render API](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/wiki/Getting-started)、[源项目许可](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/blob/develop/LICENSE)。
- VexFlow **1.2.93**,MIT,由 OSMD 使用;音乐字形由上游渲染器输出为 SVG 路径,没有额外下载网络字体。
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) **5.11.2**,MIT。
- [JSZip](https://github.com/Stuk/jszip) **3.10.1**,上游提供 MIT 或 GPLv3 双许可,本项目使用 MIT 许可选项。
- 全部已安装生产依赖的许可汇编在 app/src/vendor/THIRD_PARTY_LICENSES.txt,保留上游第三方声明。gl 可选原生依赖被显式移除;没有分发原生渲染程序。
-《Morning Lines》及其 MusicXML 由本项目编写,随仓库许可提供。没有下载或复制第三方乐谱。软件许可不授予用户上传乐谱的内容权利。源包不含用户上传文件、预览数据库或任何凭据。
+内置示例均由本项目录入为 MusicXML,没有下载或复制第三方乐谱或版本:
+- 《Minuet in G major》BWV Anh. 114,Christian Petzold(约 1725 年,旋律属公有领域);左手声部为本项目的简化编配,本版以 CC0 1.0 提供。
+- 《Ode to Joy》,贝多芬第九交响曲 Op. 125 末乐章主题(1824 年,公有领域);弦乐四重奏编配为本项目原创,以 CC0 1.0 提供。
+- 《Morning Lines》为本项目原创练习曲,以 CC0 1.0 提供。
+
+PDF 写入器、SHA-256 与示例记谱生成器为本项目代码,未引入新的第三方依赖。软件许可不授予用户上传乐谱的内容权利。源包不含用户上传文件、预览数据库或任何凭据。
diff --git a/workbenches/55-opensheetmusicdisplay/README.md b/workbenches/55-opensheetmusicdisplay/README.md
index 51882cb..30f74e4 100644
--- a/workbenches/55-opensheetmusicdisplay/README.md
+++ b/workbenches/55-opensheetmusicdisplay/README.md
@@ -1,43 +1,30 @@
-# 乐谱审校室 · OpenSheetMusicDisplay
+# 乐谱审校台 · OpenSheetMusicDisplay
-当前版本:**0.3.0**(自动保存与产品化布局)。
-导入已有 MusicXML/MXL,在真实乐谱上核对声部与版式,按小节留下审校记录。原文件、显示设置、当前审阅位置和批注一起保存,刷新后可重新打开。支持导出当前谱面 SVG、原始文件、MXL 中的 MusicXML,以及带原件 SHA-256 的批注报告 JSON。
+当前版本:**0.9.0**。导入制谱软件导出的 MusicXML / MXL(最大 32 MB),在 OpenSheetMusicDisplay 渲染的真实谱面上逐音移动光标、回放、移调、切换声部与版式;在音符或小节上留下编号批注,批注直接画在谱面上并与右侧账本联动;导出带批注的 PDF 校样、PNG、SVG 与批注 JSON。原文件逐字节保留。
-软件用于乐谱审阅,不编辑音符、不提供音频播放。内置《Morning Lines》为本项目原创双声部示例,没有复制第三方乐谱。
+
## 使用
-在总仓库执行 `pnpm workbench prepare opensheetmusicdisplay`,进入本目录执行:
+在总仓库执行 `pnpm workbench prepare opensheetmusicdisplay`,进入本目录:
```sh
-pnpm dev
+pnpm dev # 打开打印出的完整地址
pnpm workbench:check
pnpm test
pnpm package
```
-打开预览命令打印的完整地址。新建或导入乐谱,选择显示声部;在小节索引中选择批注声部与小节,再添加记录、标记已核对。修改后自动保存,可从顶部稿件列表重新打开。窄屏使用小节选择和“转到批注区”,谱纸在独立区域横向滚动。
-
-`artifacts/opensheetmusicdisplay-studio-0.3.0.fbworkbench` 可通过 **Floatboat → My → Workbenches → Import workbench** 导入。源包不包含预览数据库;客户端安装流程尚未验收。
+首开即是示例《小步舞曲 G 大调》。在谱面上点击音符定位(蓝色光标跟随),「在此添加批注」写下问题并选类别;←/→ 逐音移动光标、Shift+←/→ 逐小节、空格回放、N 添加批注。「乐谱库」菜单负责导入、示例、最近的审校、恢复草稿与删除;修改自动保存。
## 支持范围
-- UTF-8、score-partwise MusicXML;原文件 1–200 KB,XML 内容不超过 180 KB。
-- 1–12 个声部,每声部 1–200 小节,总计 1–4000 个音符或休止符;最多 100 条批注,每条 1000 字符。
-- 不接受 DOCTYPE、ENTITY、外部图片/链接/脚本及样式处理指令;带外部 DTD 的文件需先在制谱软件中导出不含声明的版本。不会访问外部 DTD 或网络字体。
-- MXL 使用 META-INF/container.xml 的第一个 rootfile;只支持普通非加密 ZIP(Stored/Deflate),最多 32 个条目,声明解压总量不超过 500 KB,每条目不超过 180 KB。另对实际解压流限额,拒绝路径穿越、重复条目及 ZIP64。
-- SVG 对应当前显示声部、版式和比例,批注作为报告单独导出。源文件下载保留上传字节;报告是审校清单,不是完整项目备份或 MusicXML 制谱文件。
-
-## 验收
-
-2026-10-27:严格模板构建、12 项契约/业务/数据库测试通过。Chrome 真实 MusicXML/MXL 导入、声部筛选、版式与比例、小节批注及核对状态、保存刷新恢复、原件逐字节下载通过;无效 XML/外部声明拒绝后保留原工作,切换项目会先同步工作草稿。SVG 独立打开有真实音符路径,源 XML 独立打开并通过 XML 解析;报告包含原件哈希与正确小节锚点。375px、暗色、reduced-motion、键盘导航均检查。
-
-模型与外部服务不是本应用功能,manifest 没有 llm 能力。上游版本与许可见 [NOTICE.md](NOTICE.md),工程边界见 [app/ENGINEERING.md](app/ENGINEERING.md)。
-
-自动保存:停止输入约 700 ms 后保存,后台请求不锁编辑。未写完的空白批注、显示设置及当前小节可在刷新后恢复;不合法的业务输入显示“草稿已自动保存,待完善输入”,不会覆盖最后的合法内容。网络失败时保留本机副本,版本冲突提供恢复草稿导出与服务器版本入口。导入先验证,再创建独立项目。
-
-当前布局:谱面为主体、右侧小节索引与审校批注;375px、暗色与减少动画偏好保持可用。
+- `.musicxml` / `.xml`(UTF-8、UTF-16 等)与 `.mxl`;原文件 ≤ 32 MB,解压后的 MusicXML ≤ 64 MB;≤ 64 声部、每声部 ≤ 5000 小节、≤ 40 万音符。标准 MusicXML DOCTYPE 可直接导入(不下载 DTD);DTD 内部子集、实体声明、样式表处理指令被拒绝;外部图片与链接仅提示忽略。
+- 版式:A4 竖版/横版分页、无限滚动、单行横卷;缩放 30–300%;移调 ±12 半音;声部显示开关;小节号、标题/作曲、声部名可切换。
+- 批注:锚定声部 + 小节 + 可选拍位,7 种类别,最多 1000 条;空白批注保存为草稿。
+- 导出:PDF(谱页 + 批注清单页,谱页为 JPEG 图像)、PNG(当前页)、SVG(全部页)、批注 JSON v2、原文件、MXL 展开的 MusicXML。
+- 回放为 Web Audio 简单合成音色,不代表乐器音色与演奏法。
-## 0.3.0 保存基础修复
+示例:Petzold《小步舞曲 G 大调》BWV Anh. 114(旋律公有领域,左手为本项目简化编配)、贝多芬《欢乐颂》主题(公有领域,本项目弦乐四重奏编配)、《Morning Lines》(本项目原创)。均由本项目录入,未复制第三方版本。
-快速编辑后撤回到已确认内容时,保存状态和本机工作副本一起恢复;服务端草稿回执与缓存重试按统一协议处理。整合验收见 [0.3.0 发布记录](../../docs/release-0.3.0.md)。
+验收记录见 [app/ACCEPTANCE.md](app/ACCEPTANCE.md),工程边界见 [app/ENGINEERING.md](app/ENGINEERING.md),上游版本与许可见 [NOTICE.md](NOTICE.md)。`artifacts/opensheetmusicdisplay-studio-0.9.0.fbworkbench` 可通过 **Floatboat → My → Workbenches → Import workbench** 导入;客户端安装流程尚未验收。
diff --git a/workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md b/workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md
new file mode 100644
index 0000000..abcff42
--- /dev/null
+++ b/workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md
@@ -0,0 +1,65 @@
+# 乐谱审校台 0.9.0 验收
+
+日期 2026-10-29。功能验收与结构审查分别记录。
+
+## 之前 → 现在
+
+| 方面 | 0.3.1 | 0.9.0 |
+| --- | --- | --- |
+| 真实输入 | 原文件 ≤ 200 KB、XML ≤ 180 KB、≤ 12 声部 × 200 小节;**任何带 DOCTYPE 的文件被拒**(制谱软件导出的 MusicXML 几乎都带);外部图片引用即拒 | 原文件 ≤ 32 MB(分块上传进原件库)、解压后 ≤ 64 MB、≤ 64 声部 × 5000 小节;接受标准 DOCTYPE(不下载),只拒绝内部子集/实体/样式表;图片与链接仅提示忽略;UTF-16、Latin-1;MXL 跳过 PDF rootfile、缺容器时兜底 |
+| 光标与回放 | 无;README 声明“不提供音频播放” | OSMD 光标逐音/逐小节移动、点击谱面跳到拍位;Web Audio 回放驱动光标,速度 50–150% |
+| OSMD 能力 | 单一“显示声部”下拉、A4/连续、5 档比例 | 声部多选开关、A4 竖/横、无限滚动、单行横卷、30–300% 缩放与适合宽度、TransposeCalculator 移调 ±12、小节号三档、标题/作曲与声部名开关 |
+| 批注 | 只在右侧表单,绑定小节;不显示在谱面 | 锚定小节 + 拍位,7 类;按乐谱顺序编号画在每页 SVG 内(待核对/已核对/选中三色),点谱面编号 ↔ 账本条目双向定位;筛选全部/待核对/本小节 |
+| 导出 | 无批注的 SVG、原件、报告 JSON | PDF 校样(谱页含批注编号 + 中文批注清单页)、PNG 当前页、SVG 全部页(含批注)、批注 JSON v2、原文件、展开的 MusicXML |
+| 示例 | 8 小节原创双声部 | 小步舞曲(钢琴 32 小节、两段反复、力度与速度)、欢乐颂弦乐四重奏(4 声部 16 小节)、Morning Lines |
+| 页头 | “稿件”菜单(项目下拉/新建/删除)+ 立即保存主按钮 + 两行文件/显示工具条 | 「乐谱库」菜单承载导入、示例、最近审校、恢复草稿、删除;保存只显示状态(离线/冲突时“重试保存”) |
+
+## 功能验收:local-verified
+
+### 命令(Node 22.23.4)
+
+- `pnpm workbench:check`:manifest、依赖、类型、构建通过(仅共享 `autosave-client.ts` 体积提示)。
+- `pnpm test`:**17/17** 通过(0.3.1 为 12)。覆盖:三份示例的时值自检、索引与授权说明;同步 SHA-256 与 Node 一致;UTF-8 BOM/UTF-16LE/BE/ISO-8859-1 解码与非法字节;标准 DOCTYPE 接受、内部子集/ENTITY/自定义实体/样式表拒绝、外部图片仅警告、语法错误行号、timewise 提示;弱起小节号、拍号延续、credit 标题兜底、40 声部 × 300 小节、65 声部拒绝;约 8 MB MusicXML 与 MXL 读取;MXL rootfile 选择、缺容器兜底、穿越/伪装/257 条目/加密/真实超限/伪报长度;模型 13 类非法输入与原型键、草稿放宽空白正文、批注排序、拍位换算;批注 JSON v2;PDF 交叉引用偏移与 JPEG 尺寸;原件库多块上传/去重/MXL 解压、缺块 409/篡改/编码/序号/超限 413/无效乐谱 422;示例入库与保存前索引核对;0.3 旧项目与草稿迁移;自动保存幂等、空白批注草稿不覆盖正式内容、过期版本冲突、缺原件只存草稿、导入另建项目。
+- `pnpm package`:`artifacts/opensheetmusicdisplay-studio-0.9.0.fbworkbench`,约 55 个文件,不含业务数据(产物哈希随最终提交记录在整合报告中)。
+
+### 真实浏览器(Chrome for Testing 1228 headless + puppeteer-core,`FLOATBOAT_HOST_MODE=offline`)
+
+1440×900:
+
+- 空数据目录首开:服务端 0 个项目,谱面直接渲染《小步舞曲》,两条示范批注以编号 1、2 画在第 8、20 小节;状态“编辑后自动保存”。
+- 点击第 13 小节第 3 拍的音符 → 位置卡“第 13 小节 · 第 3 拍”、锚定到音符,朱红框与拍位竖线、蓝色光标同到该处;“在此添加批注”并输入 → 谱面出现第 3 个编号,账本按乐谱顺序重排,状态 saved。
+- 键盘:→ ×2 到第 14 小节第 1 拍,Shift+→ 到第 15 小节;空格回放/暂停;点谱面编号 → 账本对应条目高亮并滚入视野。
+- 移调 +2:调号由 G 大调变为 A 大调(三个升号),批注标记仍在原小节。
+- 导出:批注 JSON(3 条、编号/小节号/拍位/类别、sha256 等于原件);SVG 556 KB 含批注层;PNG 2580×3649;PDF 2 页(macOS Quick Look 渲染谱页含编号标记,第 2 页为中文批注清单);原文件与上传字节一致(29 984 B)。导出不带“选中”高亮。
+- 导入失败:截断的 XML → “XML 语法无效(第 3 行:…)”;带 `` 内部子集 → 安全拒绝说明;HTML 改名 .xml → “缺少 score-partwise 根元素”;三次都保留当前审校。
+- 导入真实规模:`orchestral-study.mxl`(12 声部 240 小节,12 240 个音符)选文件到出图约 2.1 s,31 页;`huge-8x1200.musicxml`(5.4 MB,8 块上传,40 800 个音符)约 4.8 s,76 页。每次导入另建审校,原审校不变。
+- 示例《欢乐颂》:关闭 Violin II 与 Viola 后谱面只剩两声部、标签“声部 2/4”,Escape 关闭面板焦点回到按钮;回放 4.5 s 后位于第 2 小节第 4 拍(100 bpm 下符合),暂停后位置卡恢复“当前位置”。版式:A4 横版 1032×730、无限滚动单页、单行横卷宽 2256。
+- 原件缺失:用 API 写入一份引用未上传原件的草稿 → 打开后谱面显示“原件不在本工作台…选择原文件 evening-lines.musicxml 即可找回”;选择该文件 → “已找回原件”,批注保留,状态转为已自动保存。
+- 自动保存契约:第一次 POST 延迟 3 s,期间 phase=saving、名称仍可编辑并继续输入,刷新后为较新的“延迟保存测试 AB”;回包丢失(服务端已提交、浏览器收到网络错误)→ 自动重试 → saved,项目数 4→4、revision 13→14;空白批注 → draft“批注不能为空”,刷新后空白批注与琥珀提示恢复,补全后 saved;导入与打开示例均另建项目。
+- 控制台:除三次导入失败对应的预期 422 资源错误、缺原件场景对应的预期 404 外,无控制台或页面错误。
+
+375×812(浅色与暗色):无横向溢出(scrollWidth 375);工具条三行;谱纸为 520 px 高的独立滚动区,OSMD 以 680 px 行宽排版可横向滚动;乐谱库面板左右各留 12 px,Escape 关闭后焦点回到「乐谱库」;在窄屏点击音符并添加批注,saved。暗色下面板转深、谱纸保持白底黑字。
+
+脚本、日志、截图与测试文件:`/Users/zhangqinzhong/IdeaProjects/floatboat/archive/2026-10-29-workbench-depth/opensheetmusicdisplay/`(不入库)。
+
+### Web Interface Guidelines
+
+按规则逐项复核改动源码(本轮未联网拉取最新规则文本):图标按钮均有 aria-label/title;菜单 Escape 与外部点击关闭并还焦点;表单控件有 name、label 与 autocomplete=off;可见 focus-visible;跳转链接;`translate="no"` 用于乐谱标题、作者、声部名;数字 tabular-nums;`overscroll-behavior: contain`;`touch-action: manipulation`;删除批注与删除审校为菜单内二次确认(不使用 confirm 对话框);theme-color 按明暗配色;reduced-motion 下滚动不做平滑动画;谱面编号标记可 Tab 聚焦、Enter/空格激活。
+
+### 未覆盖与限制
+
+- **真实 Floatboat Host 未测**;本应用不声明、不调用任何 Host/模型能力。Floatboat 客户端导入源包未实测。
+- 回放在 headless 浏览器中验证了光标随时值推进与 AudioContext 正常运行,发声未经人耳试听;回放为简单合成音色,不区分乐器,不演奏装饰音、力度与渐变;乐器移调(如单簧管 B♭)不参与发声换算。
+- PDF 谱页为 JPEG 图像(约 250 dpi),不含可选中文字;“无限滚动/单行横卷”下导出 PDF 时另做 A4 排版。
+- OSMD 排版在主线程,上百页的总谱会让界面短暂停顿;没有 Web Worker 排版。
+- 批注锚点按 MusicXML 小节顺序(声部 + 序号 + 拍位),不支持跨小节区间或单个声部内多声部(voice)的区分。
+- 原件库中不再被引用的原件不会自动回收。本地恢复依赖同源 IndexedDB,0.3 版本留在浏览器 IndexedDB 中尚未同步的本机副本无法转换(服务端已保存的项目与草稿会在启动时迁移)。
+- 大文件验收用的是生成的测试谱,没有用 MuseScore/Finale/Sibelius 的原始导出文件;DOCTYPE、UTF-16、MXL 容器等按 MusicXML 4.0 规范构造测试。
+
+## 结构审查:pass(待主代理整合确认)
+
+- 主任务:谱纸占桌面约 75% 宽、工具条一行贴在谱纸上沿;右侧 360 px 批注账本,顶部是当前位置与“在此添加批注”,下方为编号批注列表。旧的“稿件菜单 + 立即保存 + 两行文件/显示工具条 + 下拉式逐节核对表单”已删除。
+- 文件生命周期:「乐谱库」菜单(导入、示例、最近的审校、恢复草稿、打开服务器版本、删除);没有项目选择器与新建/立即保存组合;保存为状态文字。
+- 对照截图:PageIndex(`docs/screenshots/pageindex.png`)是目录 + 原页 + 问答三栏,本台无目录、定位在谱面上完成;0.3.1 本台旧截图(审计基线)中批注与谱面分离,现在批注直接标在谱面并随导出保留。
+- 空态与有内容:空数据目录首开即可点谱、写批注、回放;没有批注的示例显示账本空态说明。
+- 截图:`docs/screenshots/opensheetmusicdisplay.png`(1440×900,空数据目录首开后添加一条批注)。
diff --git a/workbenches/55-opensheetmusicdisplay/app/DESIGN.md b/workbenches/55-opensheetmusicdisplay/app/DESIGN.md
index 94253e1..6d50c71 100644
--- a/workbenches/55-opensheetmusicdisplay/app/DESIGN.md
+++ b/workbenches/55-opensheetmusicdisplay/app/DESIGN.md
@@ -1,7 +1,55 @@
-# 出版校审 · 产品布局
+# 乐谱审校台 · 产品布局(0.9.0)
-用户围绕已有乐谱做出版/排练前校审。采用两列校样桌:左侧宽幅谱面阅读区,右侧窄批注边栏;声部与小节定位放在批注边栏顶部,不再使用左索引+中画布+右表单的通用三栏。文件与显示操作压成紧凑页眉,主标题不重复占据首屏。
+## 用户与主任务
-谱面始终黑字白纸,外围是冷灰工作面;朱红位置标记和已核对状态表达校审语境。批注像页边校记,与原谱分离。375px 优先看到谱面,定位/批注顺序进入同一列,并提供直接跳转。
+乐团资料员、出版社编辑、器乐教师拿到一份制谱软件导出的 MusicXML/MXL,要在排练或付印前逐处核对:听一遍、看清每个声部、在出问题的小节或音符上留下编号批注,再把带批注的校样(PDF/PNG)和批注清单交给作者或演奏者。最常做的动作依次是:点谱面定位 → 写批注 → 勾“已核对”;其次是回放、移调、切换声部与版式、导出。
-原始XML/MXL及审阅状态自动保存;空白新批注、导入检查和试排过程必须保留当前工作,不能误报已保存。源文件不改写、不加载外部资源;没有音符编辑或播放器承诺。React稳定实例、键盘焦点、暗色与reduced-motion保持可用。
+## 工作对象与路径
+
+用户面对的对象是“一份乐谱的审校”(以乐谱标题命名,例如“小步舞曲 G 大调 · 排练前校对”),内部存为 project。
+
+- **首次使用**:空数据目录首开即是内置示例《小步舞曲 G 大调》(Petzold,BWV Anh. 114)及两条示范批注,可直接点谱、回放、写批注,不必先建项目。
+- **继续上次工作**:自动保存的最近审校在「乐谱库」菜单里,刷新后回到上次的文档、显示设置与审阅位置。
+- **导入**:「乐谱库 → 导入 MusicXML / MXL」。原文件分块上传到原件库,服务端解析通过、浏览器试排前 8 小节后,才另建一份新审校;失败时当前工作不变,错误说明原因(XML 第几行、DTD 内部子集、不是 score-partwise 等)。
+- **示例**:「乐谱库 → 示例乐谱」三份,每次打开另建一份审校。
+- **恢复**:审校引用的原件不在本机(例如从其他环境恢复的草稿)时,谱面位置显示“选择原文件…”,选择内容一致的原文件即找回,批注不动;内容不同则作为新导入。冲突时菜单出现“打开服务器版本”,任何时候可“导出恢复草稿”。
+- **工作副本**:编辑即建立,约 700 ms 后自动保存;空白批注作为草稿保存但不进入正式版本。
+
+## 空间关系
+
+```
+┌ 页眉:𝄞 谱校 · [乐谱库▾] · 审校名称 / 著录(作曲·编配·声部·小节·原件大小)· 保存状态 ┐
+├ 谱面工具条:光标/回放/速度 · 缩放 · 版式 · 移调 · 声部▾ · 导出▾ ──────────┬ 批注账本 ─┤
+│ │ 当前位置 │
+│ 宽幅谱纸(OSMD SVG,白纸) │ 声部/小节 │
+│ · 蓝色光标(逐音移动、回放时跟随) │ 锚定方式 │
+│ · 朱红框:当前小节;竖线:当前拍位 │ [在此添加] │
+│ · 编号圆标:批注(朱红=待核对,灰=已核对,蓝=选中) │ 筛选 │
+│ │ 编号批注列表│
+├ 页数 · 音符数 · 移调状态 · 版权说明 ───────────────────────────────────┴───────────┤
+```
+
+主操作贴着对象:定位在谱面上点击完成,添加批注在当前位置卡片里;回放、移调、版式与导出在谱纸上沿。文件生命周期只在「乐谱库」菜单:导入、示例、最近审校、恢复草稿、删除(菜单内二次确认)。没有“项目选择 + 新建 / 立即保存”页头组合;保存只有状态文字,离线或冲突时出现“重试保存”,⌘/Ctrl+S 可立即保存。
+
+谱面与账本双向联动:点谱面编号 → 账本滚动到该条并高亮;点账本编号或位置 → 谱面滚动到该小节、光标移到拍位。光标移动(←/→ 逐音,Shift+←/→ 逐小节)同步“当前位置”。
+
+## 视觉
+
+- 冷灰校样桌(#e3e7ea)+ 白纸谱面,谱纸在暗色模式下仍白底黑字;工具与账本为浅灰面板。
+- 朱红 #c2410c 只用于批注与当前位置,蓝 #2563eb 只用于光标、选中与回放状态,灰蓝表示已核对。
+- 文档标题与当前小节号用宋体(Songti SC / Noto Serif CJK),呼应出版校样;界面文字用系统无衬线,数字等宽。
+- 密度:工具条 32 px 控件;账本每条批注一张卡片,编号圆标与谱面一致。
+
+## 关键状态
+
+读取原件 / 解析 / 排版(谱面上方浮动提示);导入分块进度(保存状态处);原件缺失(谱面内找回入口);渲染失败(谱面内 alert,已保存内容不受影响);回放中(按钮变蓝“暂停”,位置卡片显示“正在回放”);空白批注(卡片内琥珀色提示,状态为“草稿已自动保存,待完善输入”);导出中(导出按钮显示进度,例如“正在生成 PDF 第 3/12 页…”)。
+
+## 窄屏(375 px)
+
+页眉换行:品牌、乐谱库、保存状态一行,审校名称与著录一行;工具条分三行(回放、缩放与版式、移调与声部/导出);谱纸固定 64 vh 的独立滚动区,OSMD 以至少 680 px 的行宽排版,可横向滚动或缩小比例;账本在谱纸下方完整展开。菜单在窄屏改为左右各留 12 px 的固定面板。
+
+## 与相邻工作台的差异
+
+- **PageIndex**(docs/screenshots/pageindex.png):左目录 + 中原页 + 右摘录/问答三栏,主区约 50%。本台没有左侧目录,谱纸占约 75% 宽;定位靠在谱面上点击与光标,而不是目录树;批注直接画进谱纸并随导出保留。
+- **旧版本台**(0.3,docs/screenshots/opensheetmusicdisplay.png 的历史版本):页眉有“稿件”菜单 + 立即保存,第二、三行是文件与显示工具条,右侧是“逐节核对”下拉表单,批注不在谱面上。现在页眉只剩乐谱库与状态,工具条一行贴着谱纸,定位改为谱面点击,批注编号显示在谱面上。
+- **Mirador**(双图光台 + 复核账本):同为“看图 + 账本”,但 Mirador 是左资料抽屉和深色光台;本台是白纸校样与音乐专用的时间轴(光标、回放、拍位)。
diff --git a/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md b/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md
index df3d03f..c6311cc 100644
--- a/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md
+++ b/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md
@@ -1,31 +1,27 @@
-# 乐谱审校工程
+# 乐谱审校工程(0.9.0)
-固定 Node 22、React 19.2.3、严格 TypeScript、官方 Vite profile。root/app 精确依赖与锁一致:opensheetmusicdisplay 2.2.1、fast-xml-parser 5.11.2、jszip 3.10.1。覆盖规则移除 OSMD 的 gl 原生可选依赖,仅使用发布的浏览器构建;没有更改 SDK、CSP 或 Vite。
+固定 Node 22、React 19.2.3、严格 TypeScript、官方 Vite profile。root/app 精确依赖与锁一致:opensheetmusicdisplay 2.2.1、fast-xml-parser 5.11.2、jszip 3.10.1、lucide-react 0.562.0。覆盖规则移除 OSMD 的 gl 原生可选依赖。0.9.0 没有新增依赖:PDF 由自带的最小写入器生成,回放用浏览器 Web Audio。
-## 模块与数据
+## 模块
-- contracts/musicxml 验证 XML 语法、资源声明、深度/规模,读取 score-partwise 声部/小节索引。
-- contracts/archive 在 JSZip 解压前检查 ZIP 中央目录;再用固定版本实际存在的 internalStream 接口做实际输出限额。上游类型缺少该方法,因此仅对已核实的方法作窄类型补充并运行时检查,不关闭类型检查。
-- contracts/domain 验证原文件编码、XML 对应关系、显示参数、审阅位置和批注锚点;archive.verifySource 在 POST/PUT 时重新解压 MXL 核实原件与渲染 XML 一致。
-- 原件 base64 和安全解析后的 XML 保存在项目 payload;限制足够小,不需要临时 blob URL 或外部路径。原件下载解码完整字节,报告记录 SHA-256。
-- server/store 使用参数化 SQL,revision 防止旧窗口覆盖新版本。迁移 0002_projects.sql 保持不变,测试使用内存 SQLite。
-- UI/App 管理工作流程,MeasureIndex 与 AnnotationBook 分离导航和批注;ScoreView memo 避免批注输入重建乐谱。OSMD 动态加载,关闭自动 resize,只在明确设置或文件变化后渲染。每次 load 绑定独立 DOM 容器;异步完成时检查活动状态,卸载清理自己的实例。
-- SVG 导出将所有谱页按真实尺寸串接成独立 SVG,白底、音符路径保留,不依赖工作台 CSS。报告 JSON 与源文件分开导出。
+- `contracts/musicxml.ts`:BOM/声明编码解码;安全检查(接受标准 DOCTYPE,拒绝内部子集/实体/样式表);fast-xml-parser 解析出声部、谱表数、小节号、拍号延续、著录(work-title / movement-title / creator / credit-words / rights)与警告。8 MB 文件约 0.7 s。
+- `contracts/archive.ts`:MXL 中央目录检查、按真实输出字节计数的流式解压(JSZip `internalStream`,上游类型缺此方法,窄类型补充并运行时检查),`readSource()` 统一入口。
+- `contracts/domain.ts`:模型 `{source: SourceRef, index, view, annotations, focus}` 与逐字段校验;`restoreScoreDraft` 只放宽空白正文。枚举值用 `Object.hasOwn` 防原型键。
+- `contracts/samples.ts` + `score-builder.ts`:记谱串生成标准 MusicXML 并逐小节检查时值;`sha256.ts` 为种子同步生成与服务端一致的原件标识。
+- `contracts/report.ts`(批注 JSON v2)、`contracts/pdf.ts`(每页一张 DCTDecode JPEG 的 PDF 1.4,UTF-16BE 标题)。
+- `server/sources.ts`:`score_sources` / `score_source_parts`(迁移 0004)。768 KB 原始字节一块(base64 后约 1 MB,低于运行时 2 MB 请求上限);finish 时校验块序、长度与 SHA-256,解析失败 422;MXL 解压结果与索引一并入库;`/api/sources/:id/musicxml` 与 `/original` 以 immutable 缓存返回。内置示例启动时写入。`verify(project)` 在自动保存事务之前核对原件存在且索引一致。
+- `server/legacy.ts`:0.3.x 项目与自动保存草稿(原件 base64 内嵌)启动时迁入原件库并改写结构,项目 revision +1,草稿与回执同步;只处理一次。
+- `score/engine.ts`:OSMD 选项映射(版式、小节号、标题、声部名、光标、关闭自动合并多小节休止以保持小节一一对应)、移调、几何查询(GraphicalMeasure 外框、拍位横坐标、点击命中最近谱表与音符)、光标定位、在每页 SVG 内绘制批注层、页面快照与拼接、屏幕外渲染。
+- `score/ScoreView.tsx`:持有 OSMD 实例;原件变化时新建,显示设置变化时同实例重排,批注/位置变化只重绘批注层。`score/playback.ts`:基于光标迭代器(clone 预读下一时值)的 Web Audio 调度。`score/exporter.ts`:SVG→canvas(data: URL,符合 CSP)→ PNG/JPEG,批注清单页用 canvas 排版中文后进 PDF。
+- UI:`App.tsx` 状态与流程,`FileMenu`(乐谱库)、`ScoreToolbar`、`Ledger`、`Menu`(Escape/外部点击关闭并还焦点)、`sources.ts`(分块上传、MusicXML 缓存两份)。
-## 边界与恢复
+## 边界
-MusicXML 不是任意 XML 查看器;不加载外部 DTD、实体、图片、链接或脚本。MXL 不支持分卷/ZIP64/加密,只处理首个 rootfile;不从文件中的 URL 发起网络请求。输入必须先通过业务校验与离屏真实试渲染,才替换 React 项目状态。
-
-所有会修改数据的控件在导入/保存/打开期间禁用。失败保留原文、批注;保存成功但列表刷新失败单独提示。空白批注不会保存,项目原件和批注在同一版本中提交。显示声部与批注声部是不同概念,批注绑定原始声部 ID + 小节序号,展示小节号单独记录,以支持弱起/重复编号。
+- 批注坐标存在 SVG 用户坐标里(OSMD 单位 × 10),因此缩放、导出与打印保持对齐;在“无限滚动/单行横卷”版式下导出 PDF 时另做一次屏幕外 A4 排版。
+- 回放为简单合成音色,不区分乐器、不演奏装饰音/力度变化;反复记号按 OSMD 迭代器展开。
+- OSMD 排版在主线程执行。本机 Chrome 从选择文件到出图:12 声部 240 小节的 MXL 约 2 s(31 页),8 声部 1200 小节的 5.4 MB MusicXML 约 5 s(76 页)。
+- 未引用的原件保留在原件库,暂无回收。
## 验证
-pnpm workbench:check → pnpm test → pnpm dev Chrome 主流程 → pnpm package。测试覆盖 XML/实体/外部资源、ZIP 路径/大小/伪报长度、原件一致性、批注锚点、报告哈希、SQLite 恢复和409冲突。浏览器覆盖真实文件与导出、刷新恢复、暗色/375px/reduced-motion、无失败资源与CSP错误。没有音频、模型或其他 Host 能力;客户端导入安装不由本地预览代替。
-
-scripts/collect-licenses.mjs 可重新生成生产依赖许可证汇编;它是维护脚本,不参与工作台运行或自定义编译。
-
-## 自动保存与工作草稿
-
-新增 0003_autosave.sql,旧迁移保持不变。autosave-server 在事务中保存完整工作副本,并仅将通过业务验证的内容写入 projects;documentId/mutationId 支持丢失响应后的幂等重试。客户端串行提交、防抖、IndexedDB 回退,documentKey/loadEpoch 仅在文档切换时更新。保存返回时不覆盖期间继续输入的内容。草稿状态与正式内容状态明确区分。
-
-音乐原件通过 verifySource 在事务之前进行异步字节对应校验;恢复器仅放宽空批注,XML/ZIP 安全和锚点检查保持。
+`pnpm workbench:check` → `pnpm test`(17 项)→ `pnpm dev` 真实浏览器(1440 / 375、暗色)→ `pnpm package`。没有 Host 能力调用;客户端导入安装不由本地预览代替。scripts/collect-licenses.mjs 可重新生成生产依赖许可证汇编。
diff --git a/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md b/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md
index 24ca814..fc503d8 100644
--- a/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md
+++ b/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md
@@ -1,17 +1,31 @@
-# 乐谱审校需求
+# 乐谱审校需求(0.9.0)
用户:乐团资料员、音乐出版社编辑、器乐教师。
-主流程:导入 MusicXML/MXL → 确认原谱渲染 → 选择声部和 A4/连续版式 → 选择声部与小节添加批注/核对状态 → 保存 → 重新打开 → 导出 SVG、原件、批注报告。
+主流程:导入 MusicXML/MXL(或打开示例)→ 确认原谱渲染 → 回放、移调、选择显示声部与版式 → 在谱面上点击音符或小节定位,添加编号批注(类别、正文、核对状态)→ 自动保存 → 重新打开 → 导出带批注的 PDF/PNG/SVG、批注 JSON、原文件。
-原始文件必须逐字节保留;审阅位置、声部筛选、比例、版式、批注一起持久化。导入先安全解析并试渲染,再替换当前工作;任何失败保持当前数据。覆盖未保存修改、删除项目及删除批注需明确确认。保存以 revision 检查并发冲突。
+## 输入
-支持 UTF-8 score-partwise;文件 200 KB、XML 180 KB、12 声部、每声部 200 小节、4000 音符/休止符、100 条批注。拒绝外部实体/资源,MXL 另检查目录路径、条目与实际解压长度。文件限制和不支持的 DTD 在 README 与错误信息中明确说明。
+- `.musicxml` / `.xml`(UTF-8、UTF-16、声明的 ISO-8859-1/Windows-1252)与压缩 `.mxl`,原文件最大 32 MB,解压后的 MusicXML 最大 64 MB。
+- 制谱软件导出的标准 DOCTYPE(只引用公开 DTD)被接受且从不下载;DTD 内部子集、ENTITY 声明、自定义实体、xml-stylesheet 被拒绝。外部图片、链接仅提示“已忽略”。
+- score-partwise;最多 64 声部、每声部 5000 小节、40 万个音符/休止符。score-timewise 给出转换提示。
+- MXL:按 container.xml 选第一个 MusicXML rootfile(跳过 PDF 等),缺少容器时使用唯一的根目录乐谱;拒绝分卷、ZIP64、加密、未知压缩、路径穿越、重复条目,实际解压流按字节限额。
+- 原文件逐字节保留在原件库(SHA-256 为键,内容去重);项目只保存原件引用、解析索引、显示设置、批注与审阅位置。
-仅实现渲染与审校,不承诺音符编辑、完整制谱、播放器或任意 MusicXML 扩展。无 AI/外部服务依赖。原创建谱示例只用于展示。
+## 能力(使用锁定的 OpenSheetMusicDisplay 2.2.1)
-验收:真实引擎输出、XML/MXL 原件恢复、批注锚点与报告、错误恢复、保存冲突、键盘与375px、dark/reduced-motion、独立打开SVG/XML、固定模板check/test/package;导入客户端安装另行验收。
+- 光标:逐音前后移动、逐小节跳转、点击谱面后光标移到该拍位;回放时跟随。
+- 回放:Web Audio 合成器按光标逐时值发声(可见声部、移调后音高、乐谱速度 × 50–150%),不加载外部音色。
+- 缩放 30–300%、适合宽度;版式 A4 竖版/横版分页、无限滚动(Endless)、单行横卷。
+- 声部显示开关(至少保留一个)、小节号(每行开头/每小节/不显示)、标题与作曲信息、声部名称。
+- 移调 ±12 半音(TransposeCalculator,调号一并改写)。
+- 批注:锚定声部 + 小节(+ 可选拍位),7 类,最多 1000 条、每条 2000 字;按乐谱顺序编号,画在谱面上,与账本双向联动;筛选全部/待核对/本小节。
+- 导出:PDF 校样(每页谱面含批注编号 + 批注清单页)、PNG 当前页、SVG 全部页、批注 JSON(v2,含原件 SHA-256、小节号、拍位、类别)、原文件、MXL 展开的 MusicXML。
-自动保存:停止输入约 700 ms 后保存,后台请求不锁编辑。未写完的空白批注、显示设置及当前小节可在刷新后恢复;不合法的业务输入显示“草稿已自动保存,待完善输入”,不会覆盖最后的合法内容。网络失败时保留本机副本,版本冲突提供恢复草稿导出与服务器版本入口。导入先验证,再创建独立项目。
+## 持久化与恢复
-当前布局:谱面为主体、右侧小节索引与审校批注;375px、暗色与减少动画偏好保持可用。
+自动保存约 700 ms 后提交,后台请求不锁编辑;空白批注保存为草稿、不覆盖正式内容;网络失败保留本机副本;冲突提供服务器版本与恢复草稿导出;导入与示例另建新审校。原件缺失时可选择同一原文件找回。0.3.x 旧项目(原件内嵌)在启动时迁入原件库。
+
+## 不做
+
+音符编辑与制谱、真实乐器音色、MIDI 输出、多人实时协作。无 AI/外部服务依赖,manifest 没有 llm 能力。
diff --git a/workbenches/55-opensheetmusicdisplay/app/index.html b/workbenches/55-opensheetmusicdisplay/app/index.html
index 0cf288f..c6dd369 100644
--- a/workbenches/55-opensheetmusicdisplay/app/index.html
+++ b/workbenches/55-opensheetmusicdisplay/app/index.html
@@ -4,7 +4,9 @@
-
+
+
+
乐谱审校室
diff --git a/workbenches/55-opensheetmusicdisplay/app/manifest.json b/workbenches/55-opensheetmusicdisplay/app/manifest.json
index 3ef8058..0cf3a99 100644
--- a/workbenches/55-opensheetmusicdisplay/app/manifest.json
+++ b/workbenches/55-opensheetmusicdisplay/app/manifest.json
@@ -3,9 +3,9 @@
"appId": "workbench.bea0c027-bedd-4ee0-ba91-00c23c23361e",
"registryName": "opensheetmusicdisplay-studio",
"name": "乐谱审校室",
- "description": "MusicXML/MXL 原件留存、声部与版式审阅、小节批注和 SVG 导出",
- "version": "0.3.1",
- "changelog": "共享自动保存修复:成功保存后立即切换文档不再误报“本机缓存尚未修复”。",
+ "description": "MusicXML/MXL 原件审校:OSMD 光标逐音移动与回放、移调、声部与版式切换,批注编号标在谱面上,导出带批注的 PDF/PNG/SVG 与批注 JSON",
+ "version": "0.9.0",
+ "changelog": "0.9.0:原件库分块上传,接住 32 MB 原文件与标准 DOCTYPE 的真实 MusicXML/MXL;光标、Web Audio 回放、移调、四种版式、声部开关与小节号;批注锚定小节/拍位并编号画在谱面上,与批注账本联动;带批注的 PDF/PNG/SVG 导出;三份公有领域/原创示例。",
"sdkVersion": "1.0.0",
"runtime": "node22-sqlite-v1",
"build": {
diff --git a/workbenches/55-opensheetmusicdisplay/app/migrations/0004_sources.sql b/workbenches/55-opensheetmusicdisplay/app/migrations/0004_sources.sql
new file mode 100644
index 0000000..0f73f3a
--- /dev/null
+++ b/workbenches/55-opensheetmusicdisplay/app/migrations/0004_sources.sql
@@ -0,0 +1,17 @@
+-- 0.9.0:乐谱原件改为按 SHA-256 存入原件库,项目只保存引用、索引、显示设置与批注。
+CREATE TABLE score_sources(
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ data BLOB NOT NULL,
+ xml TEXT,
+ score_index TEXT NOT NULL,
+ created_at TEXT NOT NULL
+);
+CREATE TABLE score_source_parts(
+ source_id TEXT NOT NULL,
+ part INTEGER NOT NULL,
+ data BLOB NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY(source_id, part)
+);
diff --git a/workbenches/55-opensheetmusicdisplay/app/package.json b/workbenches/55-opensheetmusicdisplay/app/package.json
index 6b3381d..0630c11 100644
--- a/workbenches/55-opensheetmusicdisplay/app/package.json
+++ b/workbenches/55-opensheetmusicdisplay/app/package.json
@@ -1,7 +1,7 @@
{
"name": "local-workbench",
"private": true,
- "version": "0.3.1",
+ "version": "0.9.0",
"type": "module",
"scripts": {
"start": "node --enable-source-maps dist/server/index.mjs",
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts
index 007331e..3f44b25 100644
--- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts
@@ -1,48 +1,49 @@
+// ABOUTME: 压缩 MusicXML(.mxl)的目录检查与限额解压,以及原文件到可渲染 MusicXML 的统一入口。
import JSZip from "jszip";
-import { safeXml, inspectScore, MAX_XML_BYTES } from "./musicxml.ts";
-import {
- bytesToBase64,
- originalBytes,
- type SourceFile,
- type ScoreProject,
- validateScore,
-} from "./domain.ts";
+import { decodeXml, inspectScore, MAX_XML_BYTES, safeXml, type ScoreIndex } from "./musicxml.ts";
import { isRecord } from "./project.ts";
+
+/** 上传原文件上限:普通 MusicXML 或 MXL。 */
+export const MAX_SOURCE_BYTES = 32 * 1024 * 1024;
+const MAX_ENTRIES = 256;
+const MAX_DECLARED_TOTAL = 512 * 1024 * 1024;
+
+export type SourceKind = "xml" | "mxl";
+
+export function sourceKind(fileName: string, bytes: Uint8Array): SourceKind {
+ if (!/\.(?:xml|musicxml|mxl)$/i.test(fileName)) throw new Error("请选择 .musicxml、.xml 或 .mxl 文件。");
+ const zip = bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
+ if (zip) return "mxl";
+ if (/\.mxl$/i.test(fileName)) throw new Error("这个 .mxl 不是 ZIP 压缩包,可能已损坏。");
+ return "xml";
+}
+
function safePath(name: string) {
- return (
- !!name &&
- !name.startsWith("/") &&
- !name.includes("\\") &&
- !name.split("/").includes("..") &&
- !/[\u0000-\u001f]/.test(name)
- );
+ return !!name && !name.startsWith("/") && !name.includes("\\") && !name.split("/").includes("..") && !/[\u0000-\u001f]/.test(name);
}
-export function inspectZip(bytes: Uint8Array): void {
- if (bytes.length < 22 || bytes.length > 200000)
- throw new Error("MXL 压缩文件必须小于 200 KB。");
+
+/** 解压前读取 ZIP 中央目录:拒绝分卷、ZIP64、加密、未知压缩方式、路径穿越与重复条目。 */
+export function inspectZip(bytes: Uint8Array): string[] {
+ if (bytes.length < 22) throw new Error("MXL 压缩包不完整。");
+ if (bytes.length > MAX_SOURCE_BYTES) throw new Error(`原文件超过 ${MAX_SOURCE_BYTES / 1048576} MB。`);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let end = -1;
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--)
- if (
- view.getUint32(i, true) === 0x06054b50 &&
- i + 22 + view.getUint16(i + 20, true) === bytes.length
- ) {
+ if (view.getUint32(i, true) === 0x06054b50 && i + 22 + view.getUint16(i + 20, true) === bytes.length) {
end = i;
break;
}
- if (end < 0 || view.getUint16(end + 4, true) || view.getUint16(end + 6, true))
- throw new Error("MXL ZIP 目录无效,不支持分卷。");
+ if (end < 0 || view.getUint16(end + 4, true) || view.getUint16(end + 6, true)) throw new Error("MXL 的 ZIP 目录无效(不支持分卷)。");
const count = view.getUint16(end + 10, true),
size = view.getUint32(end + 12, true),
offset = view.getUint32(end + 16, true);
- if (!count || count > 32 || offset + size > end)
- throw new Error("MXL 最多允许 32 个文件,不支持 ZIP64。");
+ if (count === 0xffff || offset === 0xffffffff) throw new Error("MXL 使用了 ZIP64,请用制谱软件重新导出。");
+ if (!count || count > MAX_ENTRIES || offset + size > end) throw new Error(`MXL 最多 ${MAX_ENTRIES} 个条目,目录需完整。`);
let cursor = offset,
total = 0;
- const names = new Set();
+ const names: string[] = [];
for (let i = 0; i < count; i++) {
- if (cursor + 46 > end || view.getUint32(cursor, true) !== 0x02014b50)
- throw new Error("MXL 文件目录损坏。");
+ if (cursor + 46 > end || view.getUint32(cursor, true) !== 0x02014b50) throw new Error("MXL 文件目录损坏。");
const flags = view.getUint16(cursor + 8, true),
method = view.getUint16(cursor + 10, true),
expanded = view.getUint32(cursor + 24, true),
@@ -50,46 +51,33 @@ export function inspectZip(bytes: Uint8Array): void {
extra = view.getUint16(cursor + 30, true),
comment = view.getUint16(cursor + 32, true);
total += expanded;
- if (
- flags & 1 ||
- ![0, 8].includes(method) ||
- expanded > MAX_XML_BYTES ||
- total > 500000
- )
- throw new Error("MXL 含加密、未知压缩或解压后过大的内容。");
+ if (flags & 1) throw new Error("MXL 已加密,无法读取。");
+ if (![0, 8].includes(method)) throw new Error("MXL 使用了不支持的压缩方式(仅支持存储与 Deflate)。");
+ if (total > MAX_DECLARED_TOTAL) throw new Error("MXL 声明的解压总量过大。");
const next = cursor + 46 + nameSize + extra + comment;
if (next > end) throw new Error("MXL 文件目录越界。");
- const name = new TextDecoder("utf-8", { fatal: true }).decode(
- bytes.subarray(cursor + 46, cursor + 46 + nameSize),
- );
- if (!safePath(name) || names.has(name))
- throw new Error("MXL 路径不安全或重复。");
- names.add(name);
+ const name = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(cursor + 46, cursor + 46 + nameSize));
+ if (!safePath(name) || names.includes(name)) throw new Error("MXL 含不安全或重复的路径。");
+ names.push(name);
cursor = next;
}
if (cursor !== offset + size) throw new Error("MXL 目录长度不一致。");
+ return names;
}
-async function boundedFile(
- zip: JSZip,
- name: string,
- limit: number,
-): Promise {
+
+/** 以实际输出字节计数的流式解压,防止伪报长度。 */
+async function boundedFile(zip: JSZip, name: string, limit: number): Promise {
const entry = zip.file(name);
- if (!entry) throw new Error("MXL 缺少指定的乐谱文件。");
+ if (!entry) throw new Error(`MXL 缺少 ${name}。`);
+ const streaming = entry as JSZip.JSZipObject & {
+ internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper;
+ };
+ if (typeof streaming.internalStream !== "function") throw new Error("MXL 流式解压接口不可用。");
+ const stream = streaming.internalStream("uint8array");
return new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
let size = 0,
failed = false;
- const streaming = entry as JSZip.JSZipObject & {
- internalStream: (
- type: "uint8array",
- ) => JSZip.JSZipStreamHelper;
- };
- if (typeof streaming.internalStream !== "function") {
- reject(new Error("MXL 流式解压接口不可用。"));
- return;
- }
- const stream = streaming.internalStream("uint8array");
stream
.on("data", (chunk) => {
if (failed) return;
@@ -97,7 +85,7 @@ async function boundedFile(
if (size > limit) {
failed = true;
stream.pause();
- reject(new Error("MXL 解压内容超过限制。"));
+ reject(new Error(`MXL 中的乐谱解压后超过 ${Math.round(limit / 1048576)} MB。`));
return;
}
chunks.push(chunk);
@@ -116,52 +104,35 @@ async function boundedFile(
.resume();
});
}
+
+/** 选择 container.xml 中第一个 MusicXML rootfile;缺少容器时退回到唯一的根目录 .xml/.musicxml。 */
export async function extractMxl(bytes: Uint8Array): Promise {
- inspectZip(bytes);
+ const names = inspectZip(bytes);
const zip = await JSZip.loadAsync(bytes, { createFolders: false });
- const containerBytes = await boundedFile(
- zip,
- "META-INF/container.xml",
- 10000,
- );
- const tree = safeXml(
- new TextDecoder("utf-8", { fatal: true }).decode(containerBytes),
- );
- const container = tree.container,
- files = isRecord(container) ? container.rootfiles : undefined,
- root = isRecord(files) ? files.rootfile : undefined;
- const rootfile = Array.isArray(root) ? root[0] : root;
- const name = isRecord(rootfile) ? rootfile["@_full-path"] : undefined;
- if (typeof name !== "string" || !safePath(name))
- throw new Error("MXL 容器未指定安全的 MusicXML 文件。");
- const xml = new TextDecoder("utf-8", { fatal: true }).decode(
- await boundedFile(zip, name, MAX_XML_BYTES),
- );
- inspectScore(xml);
- return xml;
-}
-export async function importScore(
- bytes: Uint8Array,
- fileName: string,
-): Promise {
- const kind = fileName.toLowerCase().endsWith(".mxl") ? "mxl" : "xml";
- if (!/\.(?:xml|musicxml|mxl)$/i.test(fileName))
- throw new Error("请选择 .musicxml、.xml 或 .mxl 文件。");
- if (!bytes.length || bytes.length > 200000)
- throw new Error("原文件需要 1–200 KB。");
- const xml =
- kind === "mxl"
- ? await extractMxl(bytes)
- : new TextDecoder("utf-8", { fatal: true }).decode(bytes);
- inspectScore(xml);
- return { fileName, kind, originalBase64: bytesToBase64(bytes), xml };
+ let target: string | undefined;
+ if (names.includes("META-INF/container.xml")) {
+ const { tree } = safeXml(decodeXml(await boundedFile(zip, "META-INF/container.xml", 64 * 1024)));
+ const container = tree.container,
+ files = isRecord(container) ? container.rootfiles : undefined,
+ roots = isRecord(files) ? files.rootfile : undefined;
+ const candidates = (Array.isArray(roots) ? roots : [roots]).filter(isRecord);
+ const chosen =
+ candidates.find((root) => typeof root["@_media-type"] !== "string" || /musicxml/i.test(String(root["@_media-type"]))) ?? candidates[0];
+ target = chosen && typeof chosen["@_full-path"] === "string" ? chosen["@_full-path"] : undefined;
+ if (!target || !safePath(target) || !names.includes(target)) throw new Error("MXL 容器没有指向包内的 MusicXML 文件。");
+ } else {
+ const scores = names.filter((name) => !name.includes("/") && /\.(?:xml|musicxml)$/i.test(name));
+ if (scores.length !== 1) throw new Error("MXL 缺少 META-INF/container.xml,且无法确定乐谱文件。");
+ target = scores[0];
+ }
+ return decodeXml(await boundedFile(zip, target, MAX_XML_BYTES));
}
-export async function verifySource(value: unknown): Promise {
- const data = validateScore(value);
- if (
- data.source.kind === "mxl" &&
- (await extractMxl(originalBytes(data.source))) !== data.source.xml
- )
- throw new Error("MXL 原文件与审阅内容不一致。");
- return data;
+
+/** 原文件 → 可渲染的 MusicXML 文本与索引。服务端与测试使用同一实现。 */
+export async function readSource(bytes: Uint8Array, fileName: string): Promise<{ kind: SourceKind; xml: string; index: ScoreIndex }> {
+ if (!bytes.length) throw new Error("文件为空。");
+ if (bytes.length > MAX_SOURCE_BYTES) throw new Error(`原文件超过 ${MAX_SOURCE_BYTES / 1048576} MB。`);
+ const kind = sourceKind(fileName, bytes);
+ const xml = kind === "mxl" ? await extractMxl(bytes) : decodeXml(bytes);
+ return { kind, xml, index: inspectScore(xml) };
}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts
index 18903a8..1360021 100644
--- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts
@@ -1,140 +1,230 @@
+// ABOUTME: 审校项目的数据模型与校验:原件引用、谱面索引、显示设置、批注锚点。
import { boundedText, isRecord } from "./project.ts";
-import { inspectScore, MAX_XML_BYTES } from "./musicxml.ts";
-export interface SourceFile {
+import { MAX_MEASURES, MAX_PARTS, type MeasureInfo, type PartInfo, type ScoreIndex } from "./musicxml.ts";
+import type { SourceKind } from "./archive.ts";
+
+export interface SourceRef {
+ /** 原文件 SHA-256(小写十六进制),同时是服务端原件库的键。 */
+ id: string;
fileName: string;
- kind: "xml" | "mxl";
- originalBase64: string;
- xml: string;
+ kind: SourceKind;
+ size: number;
+}
+export type Layout = "page" | "landscape" | "endless" | "line";
+export const LAYOUTS: Record = {
+ page: "A4 竖版分页",
+ landscape: "A4 横版分页",
+ endless: "无限滚动",
+ line: "单行横卷",
+};
+export type MeasureNumbers = "system" | "every" | "off";
+export interface ViewSettings {
+ hiddenParts: string[];
+ layout: Layout;
+ zoom: number;
+ /** 半音,-12…12,使用 OSMD TransposeCalculator。 */
+ transpose: number;
+ measureNumbers: MeasureNumbers;
+ credits: boolean;
+ partNames: boolean;
+ /** 回放速度百分比。 */
+ tempo: number;
}
+export const ANNOTATION_KINDS = {
+ pitch: "音高",
+ rhythm: "节奏",
+ dynamics: "力度",
+ articulation: "演奏法",
+ layout: "排版",
+ text: "文字",
+ other: "其他",
+} as const;
+export type AnnotationKind = keyof typeof ANNOTATION_KINDS;
+/** 小节内拍位:以全音符为单位的分数 [分子, 分母];null 表示整小节。 */
+export type Beat = [number, number] | null;
export interface Annotation {
id: string;
partId: string;
measureIndex: number;
+ beat: Beat;
+ kind: AnnotationKind;
text: string;
resolved: boolean;
+ createdAt: string;
}
-export interface ScoreProject {
- source: SourceFile;
+export interface Focus {
partId: string;
- layout: "page" | "continuous";
- zoom: number;
+ measureIndex: number;
+ beat: Beat;
+}
+export interface ScoreProject {
+ source: SourceRef;
+ index: ScoreIndex;
+ view: ViewSettings;
annotations: Annotation[];
- focus: { partId: string; measureIndex: number };
+ focus: Focus;
}
-export function bytesToBase64(bytes: Uint8Array): string {
- let binary = "";
- for (let i = 0; i < bytes.length; i++)
- binary += String.fromCharCode(bytes[i]);
- return btoa(binary);
+export const MAX_ANNOTATIONS = 1000;
+export const MAX_NOTE_TEXT = 2000;
+export const ZOOM_MIN = 0.3,
+ ZOOM_MAX = 3;
+
+export function defaultView(): ViewSettings {
+ return { hiddenParts: [], layout: "page", zoom: 0.8, transpose: 0, measureNumbers: "system", credits: true, partNames: true, tempo: 100 };
}
-export function originalBytes(source: SourceFile): Uint8Array {
- if (
- source.originalBase64.length > 270_000 ||
- !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
- source.originalBase64,
- )
- )
- throw new Error("原文件编码无效或过大。");
- const text = atob(source.originalBase64);
- if (text.length > 200_000 || !text.length)
- throw new Error("原文件需要 1–200 KB。");
- return Uint8Array.from(text, (c) => c.charCodeAt(0));
+export function newProject(source: SourceRef, index: ScoreIndex): ScoreProject {
+ return { source, index, view: defaultView(), annotations: [], focus: { partId: index.parts[0].id, measureIndex: 0, beat: null } };
}
+
+const str = (value: unknown, max: number, label: string): string => {
+ if (typeof value !== "string" || value.length > max) throw new Error(`${label}无效。`);
+ return value;
+};
+const int = (value: unknown, min: number, max: number, label: string): number => {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new Error(`${label}无效。`);
+ return value;
+};
+
+export function parseSource(value: unknown): SourceRef {
+ if (!isRecord(value)) throw new Error("项目需要乐谱原文件。");
+ if (typeof value.id !== "string" || !/^[0-9a-f]{64}$/.test(value.id)) throw new Error("原文件标识无效。");
+ if (value.kind !== "xml" && value.kind !== "mxl") throw new Error("不支持的原文件类型。");
+ const fileName = str(value.fileName, 200, "原文件名");
+ if (!fileName.trim() || /[\\/\u0000-\u001f]/.test(fileName)) throw new Error("原文件名无效。");
+ return { id: value.id, fileName, kind: value.kind, size: int(value.size, 1, 32 * 1024 * 1024, "原文件大小") };
+}
+
+export function parseIndex(value: unknown): ScoreIndex {
+ if (!isRecord(value) || !Array.isArray(value.parts) || !Array.isArray(value.measures) || !Array.isArray(value.warnings))
+ throw new Error("乐谱索引无效,请重新导入。");
+ if (!value.parts.length || value.parts.length > MAX_PARTS) throw new Error("乐谱索引的声部数量无效。");
+ if (!value.measures.length || value.measures.length > MAX_MEASURES) throw new Error("乐谱索引的小节数量无效。");
+ const ids = new Set();
+ const parts: PartInfo[] = value.parts.map((part) => {
+ if (!isRecord(part)) throw new Error("乐谱索引的声部无效。");
+ const id = str(part.id, 80, "声部标识");
+ if (ids.has(id)) throw new Error("声部标识重复。");
+ ids.add(id);
+ return {
+ id,
+ name: str(part.name, 200, "声部名称"),
+ abbreviation: str(part.abbreviation, 200, "声部缩写"),
+ staves: int(part.staves, 1, 8, "谱表数"),
+ measures: int(part.measures, 1, MAX_MEASURES, "小节数"),
+ };
+ });
+ const measures: MeasureInfo[] = value.measures.map((measure) => {
+ if (!isRecord(measure)) throw new Error("乐谱索引的小节无效。");
+ return { n: str(measure.n, 16, "小节号"), t: str(measure.t, 16, "拍号") };
+ });
+ if (parts.some((part) => part.measures > measures.length)) throw new Error("乐谱索引的小节数量不一致。");
+ return {
+ title: str(value.title, 200, "标题"),
+ subtitle: str(value.subtitle, 200, "副标题"),
+ composer: str(value.composer, 200, "作曲"),
+ lyricist: str(value.lyricist, 200, "作词"),
+ arranger: str(value.arranger, 200, "编曲"),
+ rights: str(value.rights, 200, "版权"),
+ parts,
+ measures,
+ notes: int(value.notes, 1, 10_000_000, "音符数"),
+ warnings: value.warnings.slice(0, 20).map((item) => str(item, 200, "警告")),
+ };
+}
+
+function parseView(value: unknown, index: ScoreIndex): ViewSettings {
+ if (!isRecord(value) || !Array.isArray(value.hiddenParts)) throw new Error("显示设置无效。");
+ const hidden = value.hiddenParts.map((id) => str(id, 80, "隐藏声部"));
+ if (new Set(hidden).size !== hidden.length || hidden.some((id) => !index.parts.some((part) => part.id === id)))
+ throw new Error("隐藏的声部不存在。");
+ if (hidden.length >= index.parts.length) throw new Error("至少显示一个声部。");
+ if (typeof value.layout !== "string" || !Object.hasOwn(LAYOUTS, value.layout)) throw new Error("版式无效。");
+ if (typeof value.zoom !== "number" || !Number.isFinite(value.zoom) || value.zoom < ZOOM_MIN || value.zoom > ZOOM_MAX)
+ throw new Error("缩放比例无效。");
+ if (!["system", "every", "off"].includes(value.measureNumbers as string)) throw new Error("小节号设置无效。");
+ if (typeof value.credits !== "boolean" || typeof value.partNames !== "boolean") throw new Error("标题或声部名设置无效。");
+ return {
+ hiddenParts: hidden,
+ layout: value.layout as Layout,
+ zoom: Math.round(value.zoom * 100) / 100,
+ transpose: int(value.transpose, -12, 12, "移调"),
+ measureNumbers: value.measureNumbers as MeasureNumbers,
+ credits: value.credits,
+ partNames: value.partNames,
+ tempo: int(value.tempo, 25, 200, "回放速度"),
+ };
+}
+
+function parseBeat(value: unknown): Beat {
+ if (value === null) return null;
+ if (!Array.isArray(value) || value.length !== 2) throw new Error("拍位无效。");
+ const [n, d] = value;
+ int(n, 0, 1_000_000, "拍位");
+ int(d, 1, 1_000_000, "拍位");
+ return [n as number, d as number];
+}
+
+function anchor(index: ScoreIndex, partId: unknown, measureIndex: unknown) {
+ const part = index.parts.find((item) => item.id === partId);
+ if (!part || typeof measureIndex !== "number" || !Number.isInteger(measureIndex) || measureIndex < 0 || measureIndex >= part.measures)
+ throw new Error("批注的小节或声部不存在。");
+ return { partId: part.id, measureIndex };
+}
+
export function validateScore(value: unknown): ScoreProject {
return parseScore(value, false);
}
+/** 恢复工作副本时放宽:允许尚未填写的空白批注;结构、锚点与大小检查不变。 */
export function restoreScoreDraft(value: unknown): ScoreProject {
return parseScore(value, true);
}
function parseScore(value: unknown, draft: boolean): ScoreProject {
- if (!isRecord(value) || !isRecord(value.source))
- throw new Error("项目需要乐谱原文件。");
- const input = value.source;
- if (input.kind !== "xml" && input.kind !== "mxl")
- throw new Error("不支持的原文件类型。");
- const source: SourceFile = {
- fileName: boundedText(input.fileName, 160),
- kind: input.kind,
- originalBase64: boundedText(input.originalBase64, 270000),
- xml: boundedText(input.xml, MAX_XML_BYTES),
- };
- if (!source.fileName.trim() || /[\\/\u0000-\u001f]/.test(source.fileName))
- throw new Error("原文件名无效。");
- const bytes = originalBytes(source);
- if (
- source.kind === "xml" &&
- new TextDecoder("utf-8", { fatal: true }).decode(bytes) !== source.xml
- )
- throw new Error("MusicXML 原文件与审阅内容不一致。");
- const info = inspectScore(source.xml);
- if (
- typeof value.partId !== "string" ||
- (value.partId !== "" && !info.parts.some((p) => p.id === value.partId))
- )
- throw new Error("显示声部不存在。");
- if (value.layout !== "page" && value.layout !== "continuous")
- throw new Error("版式无效。");
- if (
- typeof value.zoom !== "number" ||
- ![0.7, 0.85, 1, 1.15, 1.3].includes(value.zoom)
- )
- throw new Error("缩放级别无效。");
- if (!Array.isArray(value.annotations) || value.annotations.length > 100)
- throw new Error("最多保留 100 条批注。");
- const focus =
- value.focus === undefined
- ? { partId: info.parts[0].id, measureIndex: 0 }
- : value.focus;
- if (
- !isRecord(focus) ||
- typeof focus.partId !== "string" ||
- typeof focus.measureIndex !== "number" ||
- !Number.isInteger(focus.measureIndex) ||
- focus.measureIndex < 0 ||
- !info.parts.some(
- (part) =>
- part.id === focus.partId &&
- Number(focus.measureIndex) < part.measures.length,
- )
- )
- throw new Error("当前审阅小节无效。");
+ if (!isRecord(value)) throw new Error("项目内容无效。");
+ const source = parseSource(value.source);
+ const index = parseIndex(value.index);
+ const view = parseView(value.view, index);
+ if (!Array.isArray(value.annotations) || value.annotations.length > MAX_ANNOTATIONS)
+ throw new Error(`最多保留 ${MAX_ANNOTATIONS} 条批注。`);
const ids = new Set();
- const annotations = value.annotations.map((item) => {
- if (
- !isRecord(item) ||
- typeof item.id !== "string" ||
- !/^[-\w]{1,80}$/.test(item.id) ||
- ids.has(item.id) ||
- typeof item.partId !== "string" ||
- typeof item.measureIndex !== "number" ||
- !Number.isInteger(item.measureIndex) ||
- typeof item.resolved !== "boolean"
- )
+ const annotations = value.annotations.map((item): Annotation => {
+ if (!isRecord(item) || typeof item.id !== "string" || !/^[-\w]{1,80}$/.test(item.id) || ids.has(item.id) || typeof item.resolved !== "boolean")
throw new Error("批注结构无效。");
ids.add(item.id);
- const part = info.parts.find((part) => part.id === item.partId);
- if (
- !part ||
- item.measureIndex < 0 ||
- item.measureIndex >= part.measures.length
- )
- throw new Error("批注的小节或声部不存在。");
- const text = boundedText(item.text, 1000);
+ if (typeof item.kind !== "string" || !Object.hasOwn(ANNOTATION_KINDS, item.kind)) throw new Error("批注类别无效。");
+ const text = boundedText(item.text, MAX_NOTE_TEXT);
if (!draft && !text.trim()) throw new Error("批注不能为空。");
+ const createdAt = str(item.createdAt, 40, "批注时间");
+ if (Number.isNaN(Date.parse(createdAt))) throw new Error("批注时间无效。");
return {
id: item.id,
- partId: item.partId,
- measureIndex: item.measureIndex,
+ ...anchor(index, item.partId, item.measureIndex),
+ beat: parseBeat(item.beat),
+ kind: item.kind as AnnotationKind,
text,
resolved: item.resolved,
+ createdAt,
};
});
- return {
- source,
- partId: value.partId,
- layout: value.layout,
- zoom: value.zoom,
- annotations,
- focus: { partId: focus.partId, measureIndex: focus.measureIndex },
- };
+ if (!isRecord(value.focus)) throw new Error("当前审阅位置无效。");
+ const focus = { ...anchor(index, value.focus.partId, value.focus.measureIndex), beat: parseBeat(value.focus.beat) };
+ return { source, index, view, annotations, focus };
+}
+
+/** 按乐谱顺序排列批注:小节 → 拍位 → 声部顺序 → 创建时间。 */
+export function orderAnnotations(project: Pick): Annotation[] {
+ const partOrder = new Map(project.index.parts.map((part, i) => [part.id, i]));
+ const at = (beat: Beat) => (beat ? beat[0] / beat[1] : -1);
+ return [...project.annotations].sort(
+ (a, b) =>
+ a.measureIndex - b.measureIndex ||
+ at(a.beat) - at(b.beat) ||
+ (partOrder.get(a.partId) ?? 0) - (partOrder.get(b.partId) ?? 0) ||
+ a.createdAt.localeCompare(b.createdAt),
+ );
+}
+
+export function bytesToBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ return btoa(binary);
}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts
index a615bf2..6ab9de3 100644
--- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts
@@ -1,140 +1,202 @@
+// ABOUTME: MusicXML 文本解码、安全检查与声部/小节索引;浏览器与 Node 共用。
import { XMLParser, XMLValidator } from "fast-xml-parser";
import { isRecord } from "./project.ts";
-export const MAX_XML_BYTES = 180_000;
+
+/** 解压或解码后的 MusicXML 文本上限(UTF-16 文件按字节计)。 */
+export const MAX_XML_BYTES = 64 * 1024 * 1024;
+export const MAX_PARTS = 64;
+export const MAX_MEASURES = 5000;
+export const MAX_NOTES = 400_000;
+
export interface PartInfo {
id: string;
name: string;
- measures: { index: number; number: string; notes: number }[];
+ abbreviation: string;
+ staves: number;
+ measures: number;
}
-export interface ScoreInfo {
+export interface MeasureInfo {
+ /** 乐谱中印的小节号(弱起、重复编号时与序号不同)。 */
+ n: string;
+ /** 拍号,例如 "3/4";未声明时为空。 */
+ t: string;
+}
+export interface ScoreIndex {
title: string;
+ subtitle: string;
composer: string;
+ lyricist: string;
+ arranger: string;
+ rights: string;
parts: PartInfo[];
- noteCount: number;
- measureCount: number;
+ measures: MeasureInfo[];
+ notes: number;
+ /** 导入时被忽略的内容(外部图片、链接等),不影响渲染。 */
+ warnings: string[];
}
-export function safeXml(xml: string): Record {
- if (new TextEncoder().encode(xml).length > MAX_XML_BYTES)
- throw new Error("XML 超过 180 KB,请导出较短的乐谱。");
- if (//g, "");
- let depth = 0;
- for (const match of cleaned.matchAll(/<(?:(?:"[^"]*"|'[^']*')|[^'">])*>/g)) {
- const tag = match[0];
- if (tag.startsWith("") || tag.startsWith("")) depth++;
- if (depth > 40) throw new Error("XML 层级过深。");
+
+const ENCODINGS = new Set(["utf-8", "utf8", "utf-16", "utf-16le", "utf-16be", "iso-8859-1", "latin1", "windows-1252", "us-ascii", "ascii"]);
+
+/** 按 BOM 与 XML 声明解码;无法识别或包含非法字节时报错,不做静默替换。 */
+export function decodeXml(bytes: Uint8Array): string {
+ if (!bytes.length) throw new Error("文件为空。");
+ if (bytes.length > MAX_XML_BYTES) throw new Error(`MusicXML 超过 ${MAX_XML_BYTES / 1048576} MB。`);
+ let label = "utf-8";
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) label = "utf-16le";
+ else if (bytes[0] === 0xfe && bytes[1] === 0xff) label = "utf-16be";
+ else if (bytes[0] === 0x3c && bytes[1] === 0x00) label = "utf-16le";
+ else if (bytes[0] === 0x00 && bytes[1] === 0x3c) label = "utf-16be";
+ else {
+ const head = new TextDecoder("latin1").decode(bytes.subarray(0, 200));
+ const declared = /^\s*(?:|)?<\?xml[^>]*encoding\s*=\s*["']([\w.-]+)["']/i.exec(head)?.[1]?.toLowerCase();
+ if (declared && !declared.startsWith("utf-16")) {
+ if (!ENCODINGS.has(declared)) throw new Error(`不支持的文本编码 ${declared},请导出 UTF-8 MusicXML。`);
+ label = declared;
+ }
+ }
+ try {
+ return new TextDecoder(label, { fatal: true }).decode(bytes);
+ } catch {
+ throw new Error("文件编码无效:请确认是完整的 MusicXML(UTF-8 或 UTF-16)。");
}
+}
+
+/**
+ * 检查后解析 XML。标准的 MusicXML DOCTYPE(只引用公开 DTD、无内部子集)被接受且从不下载;
+ * 内部子集、ENTITY 声明与自定义实体被拒绝。外部图片与链接仅记为警告,渲染器不会加载它们。
+ */
+export function safeXml(xml: string): { tree: Record; warnings: string[] } {
+ if (xml.length > MAX_XML_BYTES)
+ throw new Error(`MusicXML 超过 ${MAX_XML_BYTES / 1048576} MB。`);
+ const doctype = /[]*(\[)?/i.exec(xml);
+ if (doctype?.[1] || / {
- if (++count > 30_000 || level > 45)
- throw new Error("XML 结构超出支持范围。");
- if (Array.isArray(node)) {
- for (const item of node) walk(item, level + 1);
- } else if (isRecord(node)) {
- for (const [key, value] of Object.entries(node)) {
- if (
- /(?:^|:)(?:image|credit-image|link|script|iframe|object)$/i.test(
- key,
- ) ||
- (/^@_/.test(key) && /(?:href|src|schemaLocation)$/i.test(key)) ||
- /^@_(?:on[a-z]+|style)$/i.test(key)
- )
- throw new Error("乐谱不能引用图片、链接、脚本或其他外部资源。");
- walk(value, level + 1);
- }
- }
- };
- walk(parsed);
- return parsed;
+ const warnings: string[] = [];
+ const images = (xml.match(/<(?:credit-)?image\b/g) ?? []).length;
+ const links = (xml.match(/
- v === undefined ? [] : Array.isArray(v) ? v : [v];
-const label = (v: unknown, fallback = "") =>
- typeof v === "string"
- ? v.slice(0, 200)
- : isRecord(v) && typeof v["#text"] === "string"
- ? v["#text"].slice(0, 200)
- : fallback;
-export function inspectScore(xml: string): ScoreInfo {
- const tree = safeXml(xml),
- score = tree["score-partwise"];
- if (!isRecord(score))
- throw new Error(
- "仅支持 score-partwise MusicXML;请在制谱软件中导出按声部组织的文件。",
- );
+
+const list = (v: unknown): unknown[] => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
+const text = (v: unknown, fallback = ""): string => {
+ if (typeof v === "string") return v.replace(/\s+/g, " ").trim().slice(0, 200) || fallback;
+ if (Array.isArray(v)) return text(v[0], fallback);
+ if (isRecord(v) && typeof v["#text"] === "string") return text(v["#text"], fallback);
+ return fallback;
+};
+
+/** 读取 score-partwise 的声部、小节号、拍号与著录信息。 */
+export function inspectScore(xml: string): ScoreIndex {
+ const { tree, warnings } = safeXml(xml);
+ if (isRecord(tree["score-timewise"]))
+ throw new Error("这是按时间组织(score-timewise)的 MusicXML;请在制谱软件中导出 score-partwise。");
+ const score = tree["score-partwise"];
+ if (!isRecord(score)) throw new Error("不是 MusicXML 乐谱:缺少 score-partwise 根元素。");
const partList = score["part-list"];
- if (!isRecord(partList)) throw new Error("乐谱缺少声部列表。");
+ if (!isRecord(partList)) throw new Error("乐谱缺少声部列表(part-list)。");
const declarations = list(partList["score-part"]);
const bodies = list(score.part);
- if (
- !declarations.length ||
- declarations.length > 12 ||
- bodies.length !== declarations.length
- )
- throw new Error("乐谱需要 1–12 个完整声部。");
+ if (!declarations.length) throw new Error("乐谱没有声部。");
+ if (declarations.length > MAX_PARTS) throw new Error(`最多支持 ${MAX_PARTS} 个声部。`);
+ const byId = new Map>();
+ for (const body of bodies)
+ if (isRecord(body) && typeof body["@_id"] === "string") byId.set(body["@_id"], body);
const seen = new Set();
- let noteCount = 0;
- const parts = declarations.map((declaration) => {
+ let notes = 0;
+ let longest: unknown[] = [];
+ const parts: PartInfo[] = declarations.map((declaration) => {
if (!isRecord(declaration)) throw new Error("声部声明无效。");
const id = declaration["@_id"];
- if (typeof id !== "string" || !/^[-\w.]{1,80}$/.test(id) || seen.has(id))
+ if (typeof id !== "string" || !/^[-\w.:]{1,80}$/.test(id) || seen.has(id))
throw new Error("声部标识无效或重复。");
seen.add(id);
- const matches = bodies.filter(
- (body) => isRecord(body) && body["@_id"] === id,
- );
- if (matches.length !== 1 || !isRecord(matches[0]))
- throw new Error("声部内容与声明不一致。");
- const measures = list(matches[0].measure);
- if (!measures.length || measures.length > 200)
- throw new Error("每个声部需要 1–200 个小节。");
+ const body = byId.get(id);
+ if (!body) throw new Error(`声部 ${id} 已声明但没有内容。`);
+ const measures = list(body.measure);
+ if (!measures.length) throw new Error(`声部 ${id} 没有小节。`);
+ if (measures.length > MAX_MEASURES) throw new Error(`每个声部最多 ${MAX_MEASURES} 小节。`);
+ if (measures.length > longest.length) longest = measures;
+ let staves = 1;
+ for (const measure of measures) {
+ if (!isRecord(measure)) throw new Error("小节内容无效。");
+ notes += list(measure.note).length;
+ for (const attributes of list(measure.attributes))
+ if (isRecord(attributes) && attributes.staves !== undefined) {
+ const count = Number.parseInt(text(attributes.staves), 10);
+ if (Number.isInteger(count) && count > staves && count <= 8) staves = count;
+ }
+ }
+ if (notes > MAX_NOTES) throw new Error(`乐谱超过 ${MAX_NOTES.toLocaleString("en-US")} 个音符/休止符。`);
return {
id,
- name: label(declaration["part-name"], id),
- measures: measures.map((measure, index) => {
- if (!isRecord(measure)) throw new Error("小节内容无效。");
- const notes = list(measure.note);
- noteCount += notes.length;
- return {
- index,
- number: label(measure["@_number"], String(index + 1)),
- notes: notes.length,
- };
- }),
+ name: text(declaration["part-name"], id),
+ abbreviation: text(declaration["part-abbreviation"]),
+ staves,
+ measures: measures.length,
};
});
- if (!noteCount || noteCount > 4000)
- throw new Error("乐谱需要 1–4000 个音符或休止符。");
- const work = isRecord(score.work) ? score.work : {},
- identification = isRecord(score.identification) ? score.identification : {};
- const composer = list(identification.creator).find(
- (item) => isRecord(item) && item["@_type"] === "composer",
- );
+ if (!notes) throw new Error("乐谱没有音符或休止符。");
+ let time = "";
+ const measures: MeasureInfo[] = longest.map((measure, index) => {
+ const record = isRecord(measure) ? measure : {};
+ for (const attributes of list(record.attributes)) {
+ if (!isRecord(attributes)) continue;
+ const signature = list(attributes.time)[0];
+ if (isRecord(signature)) {
+ const beats = text(signature.beats),
+ type = text(signature["beat-type"]);
+ if (/^\d+(?:\+\d+)*$/.test(beats) && /^\d+$/.test(type)) time = `${beats}/${type}`;
+ }
+ }
+ return { n: text(record["@_number"], String(index + 1)).slice(0, 16), t: time };
+ });
+ const work = isRecord(score.work) ? score.work : {};
+ const identification = isRecord(score.identification) ? score.identification : {};
+ const creator = (type: string) =>
+ text(list(identification.creator).find((item) => isRecord(item) && item["@_type"] === type));
+ const credit = (type: string) => {
+ for (const item of list(score.credit))
+ if (isRecord(item) && list(item["credit-type"]).some((value) => text(value) === type))
+ return text(list(item["credit-words"])[0]);
+ return "";
+ };
return {
- title: label(
- work["work-title"],
- label(score["movement-title"], "未命名乐谱"),
- ),
- composer: label(composer),
+ title: text(work["work-title"]) || text(score["movement-title"]) || credit("title") || "未命名乐谱",
+ subtitle: text(work["work-title"]) ? text(score["movement-title"]) || credit("subtitle") : credit("subtitle"),
+ composer: creator("composer") || credit("composer"),
+ lyricist: creator("lyricist") || creator("poet") || credit("lyricist"),
+ arranger: creator("arranger"),
+ rights: text(list(identification.rights)[0]) || credit("rights"),
parts,
- noteCount,
- measureCount: Math.max(...parts.map((part) => part.measures.length)),
+ measures,
+ notes,
+ warnings,
};
}
+
+/** 拍位(小节内的全音符分数)转为第几拍,例如 3/4 拍中 1/4 → 2。 */
+export function beatLabel(beat: readonly [number, number] | null, time: string): string {
+ if (!beat) return "";
+ const type = Number(time.split("/")[1]) || 4;
+ const value = 1 + (beat[0] / beat[1]) * type;
+ return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
+}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/pdf.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/pdf.ts
new file mode 100644
index 0000000..f8b2b48
--- /dev/null
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/pdf.ts
@@ -0,0 +1,85 @@
+// ABOUTME: 最小 PDF 1.4 写入器:每页嵌入一张 JPEG(DCTDecode),用于导出带批注的谱面校样。
+export interface PdfPage {
+ jpeg: Uint8Array;
+ /** 图像像素尺寸。 */
+ width: number;
+ height: number;
+ /** 页面尺寸(pt,1/72 英寸)。 */
+ pageWidth: number;
+ pageHeight: number;
+}
+
+export function jpegSize(bytes: Uint8Array): { width: number; height: number } {
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error("不是 JPEG 图像。");
+ let offset = 2;
+ while (offset + 9 < bytes.length) {
+ if (bytes[offset] !== 0xff) throw new Error("JPEG 结构无效。");
+ const marker = bytes[offset + 1];
+ const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker))
+ return { height: (bytes[offset + 5] << 8) | bytes[offset + 6], width: (bytes[offset + 7] << 8) | bytes[offset + 8] };
+ offset += 2 + length;
+ }
+ throw new Error("JPEG 缺少尺寸信息。");
+}
+
+export function buildPdf(pages: PdfPage[], info: { title: string; subject?: string }): Uint8Array {
+ if (!pages.length) throw new Error("没有可导出的页面。");
+ const encoder = new TextEncoder();
+ const chunks: Uint8Array[] = [];
+ const offsets: number[] = [];
+ let length = 0;
+ const push = (part: string | Uint8Array) => {
+ const bytes = typeof part === "string" ? encoder.encode(part) : part;
+ chunks.push(bytes);
+ length += bytes.length;
+ };
+ // PDF 文本字符串用 UTF-16BE 十六进制,标题可含中文。
+ const pdfText = (value: string) => {
+ let hex = "FEFF";
+ for (const unit of value.slice(0, 200)) {
+ const code = unit.codePointAt(0)!;
+ if (code > 0xffff) {
+ const v = code - 0x10000;
+ hex += (0xd800 + (v >> 10)).toString(16).padStart(4, "0") + (0xdc00 + (v & 0x3ff)).toString(16).padStart(4, "0");
+ } else hex += code.toString(16).padStart(4, "0");
+ }
+ return `<${hex.toUpperCase()}>`;
+ };
+ const object = (id: number, body: string | (string | Uint8Array)[]) => {
+ offsets[id] = length;
+ push(`${id} 0 obj\n`);
+ for (const part of Array.isArray(body) ? body : [body]) push(part);
+ push("\nendobj\n");
+ };
+ push("%PDF-1.4\n%âãÏÓ\n");
+ const pageIds = pages.map((_, i) => 4 + i * 3);
+ object(1, "<< /Type /Catalog /Pages 2 0 R >>");
+ object(2, `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`);
+ object(3, `<< /Title ${pdfText(info.title)} ${info.subject ? `/Subject ${pdfText(info.subject)} ` : ""}/Producer (Floatboat score review) >>`);
+ pages.forEach((page, i) => {
+ const id = pageIds[i];
+ const w = page.pageWidth.toFixed(2),
+ h = page.pageHeight.toFixed(2);
+ const content = `q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q`;
+ object(id, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /XObject << /Im0 ${id + 2} 0 R >> >> /Contents ${id + 1} 0 R >>`);
+ object(id + 1, `<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
+ object(id + 2, [
+ `<< /Type /XObject /Subtype /Image /Width ${page.width} /Height ${page.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>\nstream\n`,
+ page.jpeg,
+ "\nendstream",
+ ]);
+ });
+ const count = 4 + pages.length * 3;
+ const xref = length;
+ push(`xref\n0 ${count}\n0000000000 65535 f \n`);
+ for (let id = 1; id < count; id++) push(`${String(offsets[id]).padStart(10, "0")} 00000 n \n`);
+ push(`trailer\n<< /Size ${count} /Root 1 0 R /Info 3 0 R >>\nstartxref\n${xref}\n%%EOF\n`);
+ const out = new Uint8Array(length);
+ let at = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, at);
+ at += chunk.length;
+ }
+ return out;
+}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts
index dbcd3eb..7d816a2 100644
--- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts
@@ -1,35 +1,51 @@
-import { validateScore, originalBytes, type ScoreProject } from "./domain.ts";
-import { inspectScore } from "./musicxml.ts";
-export async function reviewReport(project: ScoreProject, title: string) {
- const info = inspectScore(project.source.xml);
- const data = validateScore(project),
- bytes = originalBytes(data.source),
- digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
+// ABOUTME: 批注 JSON 导出:原件哈希、谱面著录、显示设置与按乐谱顺序编号的批注(含小节号、拍位)。
+import { ANNOTATION_KINDS, orderAnnotations, validateScore, type ScoreProject } from "./domain.ts";
+import { beatLabel } from "./musicxml.ts";
+
+export function reviewReport(value: ScoreProject, title: string, exportedAt = new Date().toISOString()) {
+ const project = validateScore(value);
+ const { index } = project;
return {
format: "floatboat-score-review",
- version: 1,
+ version: 2,
title,
- exportedAt: new Date().toISOString(),
- source: {
- fileName: data.source.fileName,
- kind: data.source.kind,
- bytes: bytes.length,
- sha256: Array.from(new Uint8Array(digest), (byte) =>
- byte.toString(16).padStart(2, "0"),
- ).join(""),
+ exportedAt,
+ source: { fileName: project.source.fileName, kind: project.source.kind, bytes: project.source.size, sha256: project.source.id },
+ score: {
+ title: index.title,
+ subtitle: index.subtitle,
+ composer: index.composer,
+ lyricist: index.lyricist,
+ arranger: index.arranger,
+ rights: index.rights,
+ parts: index.parts.map(({ id, name, staves, measures }) => ({ id, name, staves, measures })),
+ measures: index.measures.length,
+ notes: index.notes,
},
- score: info,
- view: {
- partId: data.partId,
- layout: data.layout,
- zoom: data.zoom,
- focus: data.focus,
+ view: project.view,
+ summary: {
+ total: project.annotations.length,
+ open: project.annotations.filter((note) => !note.resolved).length,
+ resolved: project.annotations.filter((note) => note.resolved).length,
},
- annotations: data.annotations.map((note) => ({
- ...note,
- partName: info.parts.find((part) => part.id === note.partId)?.name,
- measureNumber: info.parts.find((part) => part.id === note.partId)
- ?.measures[note.measureIndex].number,
- })),
+ annotations: orderAnnotations(project).map((note, i) => {
+ const measure = index.measures[note.measureIndex];
+ return {
+ number: i + 1,
+ id: note.id,
+ partId: note.partId,
+ partName: index.parts.find((part) => part.id === note.partId)?.name ?? note.partId,
+ measureIndex: note.measureIndex,
+ measureNumber: measure?.n ?? String(note.measureIndex + 1),
+ beat: note.beat ? beatLabel(note.beat, measure?.t ?? "") : null,
+ offset: note.beat ? `${note.beat[0]}/${note.beat[1]}` : null,
+ kind: note.kind,
+ kindLabel: ANNOTATION_KINDS[note.kind],
+ text: note.text,
+ resolved: note.resolved,
+ createdAt: note.createdAt,
+ };
+ }),
};
}
+export type ReviewReport = ReturnType;
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/samples.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/samples.ts
new file mode 100644
index 0000000..9be399f
--- /dev/null
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/samples.ts
@@ -0,0 +1,130 @@
+// ABOUTME: 内置示例乐谱:公有领域旋律(本项目编配与录入)和本项目原创练习曲,以及首开时的种子项目。
+import { buildScore } from "./score-builder.ts";
+import { inspectScore, type ScoreIndex } from "./musicxml.ts";
+import { newProject, type ScoreProject } from "./domain.ts";
+import { sha256Hex } from "./sha256.ts";
+
+const minuetRight = [
+ "D5:q G4:e A4 B4 C5", "D5:q G4 G4", "E5:q C5:e D5 E5 F#5", "G5:q G4 G4",
+ "C5:q D5:e C5 B4 A4", "B4:q C5:e B4 A4 G4", "F#4:q G4:e A4 B4 G4", "A4:h.",
+ "D5:q G4:e A4 B4 C5", "D5:q G4 G4", "E5:q C5:e D5 E5 F#5", "G5:q G4 G4",
+ "C5:q D5:e C5 B4 A4", "B4:q C5:e B4 A4 G4", "A4:q B4:e A4 G4 F#4", "G4:h.",
+ "B5:q G5:e A5 B5 G5", "A5:q D5:e E5 F#5 D5", "G5:q E5:e F#5 G5 D5", "C#5:q B4:e C#5 A4:q",
+ "A4:e B4 C#5 D5 E5 F#5", "G5:q F#5 E5", "F#5:q A4 C#5", "D5:h.",
+ "D5:q G4:e F#4 G4:q", "E5:q G4:e F#4 G4:q", "D5:q C5 B4", "A4:e G4 F#4 G4 A4:q",
+ "D4:e E4 F#4 G4 A4 B4", "C5:q B4 A4", "B4:e D5 G4:q F#4", "G4:h.",
+].join("|");
+const minuetLeft = [
+ "G3:h +B3 +D4 A3:q", "B3:h.", "C4:h.", "B3:h.", "A3:h.", "G3:h.", "D4:h B3:q", "D4:q D3:e C4 B3 A3",
+ "B3:h A3:q", "G3:q B3 G3", "C4:h.", "B3:q C4:e B3 A3 G3", "A3:h F#3:q", "G3:h B3:q", "C4:q D4 D3", "G3:h G2:q",
+ "G3:h.", "F#3:h.", "E3:h G3:q", "A3:h A2:q", "A3:h.", "B3:h C#4:q", "D4:q F#3 A3", "D4:q D3 C4",
+ "B3:h.", "C4:h.", "B3:q A3 G3", "D4:h.", "D3:h.", "E3:h F#3:q", "G3:h D3:q", "G2:h.",
+].join("|");
+
+export const minuetXml = buildScore({
+ title: "Minuet in G major",
+ subtitle: "BWV Anh. 114",
+ composer: "Christian Petzold (c. 1725)",
+ arranger: "Left hand simplified, Floatboat edition",
+ rights: "Melody public domain; this edition CC0 1.0",
+ fifths: 1,
+ beats: 3,
+ beatType: 4,
+ tempo: { words: "Moderato", bpm: 112 },
+ repeats: { forward: [17], backward: [16, 32] },
+ dynamics: { 1: "mf", 17: "p", 25: "f" },
+ parts: [
+ { id: "P1", name: "Piano", abbreviation: "Pno.", program: 1, staves: [ { clef: "G", music: minuetRight }, { clef: "F", music: minuetLeft } ] },
+ ],
+});
+
+const odeMelody = [
+ "F#5:q F#5 G5 A5", "A5:q G5 F#5 E5", "D5:q D5 E5 F#5", "F#5:q. E5:e E5:h",
+ "F#5:q F#5 G5 A5", "A5:q G5 F#5 E5", "D5:q D5 E5 F#5", "E5:q. D5:e D5:h",
+ "E5:q E5 F#5 D5", "E5:q F#5:e G5 F#5:q D5", "E5:q F#5:e G5 F#5:q E5", "D5:q E5 A4:h",
+ "F#5:q F#5 G5 A5", "A5:q G5 F#5 E5", "D5:q D5 E5 F#5", "E5:q. D5:e D5:h",
+];
+const odeSecond = [
+ "D5:q D5 E5 F#5", "E5:q E5 D5 C#5", "A4:q A4 C#5 D5", "D5:q. C#5:e C#5:h",
+ "D5:q D5 E5 F#5", "E5:q E5 D5 C#5", "A4:q A4 C#5 D5", "C#5:q. A4:e A4:h",
+ "C#5:q C#5 D5 A4", "C#5:q D5:e E5 D5:q A4", "C#5:q D5:e E5 D5:q C#5", "A4:q C#5 E4:h",
+ "D5:q D5 E5 F#5", "E5:q E5 D5 C#5", "A4:q A4 C#5 D5", "C#5:q. A4:e A4:h",
+];
+const odeViola = [
+ "F#4:h A4", "C#4:h E4", "F#4:h A4", "A4:h G4", "F#4:h A4", "C#4:h E4", "F#4:h A4", "G4:h F#4",
+ "A4:h A4", "A4:h F#4", "A4:h G4", "F#4:h E4", "F#4:h A4", "C#4:h E4", "F#4:h A4", "G4:h F#4",
+];
+const odeCello = [
+ "D3:h F#3", "A2:h C#3", "D3:h F#3", "A2:h A2", "D3:h F#3", "A2:h C#3", "D3:h F#3", "A2:h D3",
+ "A2:h C#3", "A2:h D3", "A2:h C#3", "D3:h A2", "D3:h F#3", "A2:h C#3", "D3:h F#3", "A2:h D2",
+];
+
+export const odeXml = buildScore({
+ title: "Ode to Joy",
+ subtitle: "Theme from Symphony No. 9, Op. 125",
+ composer: "Ludwig van Beethoven (1824)",
+ arranger: "String quartet arrangement, Floatboat edition",
+ rights: "Melody public domain; this arrangement CC0 1.0",
+ fifths: 2,
+ beats: 4,
+ beatType: 4,
+ tempo: { words: "Allegro assai", bpm: 100 },
+ repeats: { forward: [9], backward: [16] },
+ dynamics: { 1: "p", 9: "mf", 13: "f" },
+ parts: [
+ { id: "P1", name: "Violin I", abbreviation: "Vln. I", program: 41, staves: [{ clef: "G", music: odeMelody.join("|") }] },
+ { id: "P2", name: "Violin II", abbreviation: "Vln. II", program: 41, staves: [{ clef: "G", music: odeSecond.join("|") }] },
+ { id: "P3", name: "Viola", abbreviation: "Vla.", program: 42, staves: [{ clef: "C", music: odeViola.join("|") }] },
+ { id: "P4", name: "Violoncello", abbreviation: "Vc.", program: 43, staves: [{ clef: "F", music: odeCello.join("|") }] },
+ ],
+});
+
+const morningSteps = [
+ ["C", "E", "G", "E"], ["D", "F", "A", "F"], ["E", "G", "B", "G"], ["F", "A", "C", "A"],
+ ["G", "B", "D", "B"], ["A", "C", "E", "C"], ["G", "F", "D", "B"], ["C", "E", "G", "C"],
+];
+export const morningXml = buildScore({
+ title: "Morning Lines",
+ composer: "Floatboat original study",
+ rights: "Original demonstration score; CC0 1.0",
+ fifths: 0,
+ beats: 4,
+ beatType: 4,
+ parts: [
+ { id: "P1", name: "Flute", abbreviation: "Fl.", program: 74, staves: [{ clef: "G", music: morningSteps.map((m) => m.map((s, i) => `${s}5${i ? "" : ":q"}`).join(" ")).join("|") }] },
+ { id: "P2", name: "Cello", abbreviation: "Vc.", program: 43, staves: [{ clef: "F", music: morningSteps.map((m) => `${m[0]}3:w`).join("|") }] },
+ ],
+});
+
+export interface Sample {
+ key: string;
+ fileName: string;
+ label: string;
+ note: string;
+ xml: string;
+}
+export const SAMPLES: Sample[] = [
+ { key: "minuet", fileName: "petzold-minuet-in-g.musicxml", label: "小步舞曲 G 大调(钢琴)", note: "Petzold,BWV Anh. 114 · 32 小节,两段反复", xml: minuetXml },
+ { key: "ode", fileName: "beethoven-ode-to-joy-quartet.musicxml", label: "欢乐颂(弦乐四重奏)", note: "贝多芬第九交响曲主题 · 4 声部 16 小节", xml: odeXml },
+ { key: "morning", fileName: "morning-lines.musicxml", label: "Morning Lines(长笛与大提琴)", note: "本项目原创练习曲 · 8 小节", xml: morningXml },
+];
+
+const encoder = new TextEncoder();
+export function sampleSource(sample: Sample): { bytes: Uint8Array; id: string; index: ScoreIndex } {
+ const bytes = encoder.encode(sample.xml);
+ return { bytes, id: sha256Hex(bytes), index: inspectScore(sample.xml) };
+}
+export function sampleProject(sample: Sample): ScoreProject {
+ const { bytes, id, index } = sampleSource(sample);
+ return newProject({ id, fileName: sample.fileName, kind: "xml", size: bytes.length }, index);
+}
+
+/** 首开时的审校对象:小步舞曲,附两条示范批注。 */
+export function seedProject(): ScoreProject {
+ const project = sampleProject(SAMPLES[0]);
+ project.annotations = [
+ { id: "seed-turn", partId: "P1", measureIndex: 7, beat: null, kind: "articulation", text: "第一段结尾:确认是否加颤音(各版本不一),排练前与演奏者统一。", resolved: false, createdAt: "2026-10-29T09:00:00.000Z" },
+ { id: "seed-csharp", partId: "P1", measureIndex: 19, beat: [0, 1], kind: "pitch", text: "转入 D 大调:升 C 需在校样中清晰可见。", resolved: true, createdAt: "2026-10-29T09:05:00.000Z" },
+ ];
+ return project;
+}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/score-builder.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/score-builder.ts
new file mode 100644
index 0000000..6ff5dea
--- /dev/null
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/score-builder.ts
@@ -0,0 +1,138 @@
+// ABOUTME: 用简短记谱串生成标准 score-partwise MusicXML,供内置示例与测试使用;会检查每小节时值。
+//
+// 记谱串:小节以 "|" 分隔,音符以空格分隔。
+// D5:q 音名+变音(#/b)+八度:时值(w h q e s,加 . 为附点),时值省略时沿用上一个
+// +B3 与前一个音同时发声(和弦),时值相同
+// r:h 休止符
+// !p 在下一个音前写力度记号(p pp mp mf f ff sf)
+const DIV = 4;
+const DURATIONS: Record = { w: [16, "whole"], h: [8, "half"], q: [4, "quarter"], e: [2, "eighth"], s: [1, "16th"] };
+
+export interface StaffSpec {
+ clef: "G" | "F" | "C";
+ music: string;
+}
+export interface PartSpec {
+ id: string;
+ name: string;
+ abbreviation: string;
+ staves: StaffSpec[];
+ /** MIDI 音色编号(1–128),供制谱软件识别,不影响本应用的回放。 */
+ program?: number;
+}
+export interface ScoreSpec {
+ title: string;
+ subtitle?: string;
+ composer: string;
+ arranger?: string;
+ rights: string;
+ fifths: number;
+ beats: number;
+ beatType: number;
+ tempo?: { words: string; bpm: number };
+ repeats?: { forward?: number[]; backward?: number[] };
+ dynamics?: Record;
+ parts: PartSpec[];
+}
+
+const escape = (value: string) => value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+
+function noteXml(token: string, state: { dur: string }, staff: number, voice: number, measure: number): { xml: string; ticks: number } {
+ let rest = token;
+ const chord = rest.startsWith("+");
+ if (chord) rest = rest.slice(1);
+ const [pitch, given] = rest.split(":");
+ if (given) state.dur = given;
+ const dotted = state.dur.endsWith(".");
+ const base = DURATIONS[state.dur.replace(".", "")];
+ if (!base) throw new Error(`第 ${measure} 小节时值无效:${token}`);
+ const ticks = dotted ? base[0] * 1.5 : base[0];
+ let body: string;
+ if (pitch === "r") body = "";
+ else {
+ const match = /^([A-G])([#b]?)(\d)$/.exec(pitch);
+ if (!match) throw new Error(`第 ${measure} 小节音高无效:${token}`);
+ const alter = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
+ body = `${match[1]}${alter ? `${alter}` : ""}${match[3]}`;
+ }
+ const xml =
+ `${chord ? "" : ""}${body}${ticks}${voice}` +
+ `${base[1]}${dotted ? "" : ""}${staff}`;
+ return { xml, ticks: chord ? 0 : ticks };
+}
+
+function staffMeasure(source: string, staff: number, measure: number, length: number): string {
+ const state = { dur: "q" };
+ let ticks = 0,
+ out = "",
+ pending = "";
+ for (const token of source.trim().split(/\s+/).filter(Boolean)) {
+ if (token.startsWith("!")) {
+ pending = token.slice(1);
+ continue;
+ }
+ if (pending) {
+ out += `<${pending}/>${staff}`;
+ pending = "";
+ }
+ const note = noteXml(token, state, staff, staff === 1 ? 1 : 5, measure);
+ out += note.xml;
+ ticks += note.ticks;
+ }
+ if (ticks !== length) throw new Error(`第 ${measure} 小节第 ${staff} 谱表时值为 ${ticks / DIV} 拍,应为 ${length / DIV} 拍。`);
+ return out;
+}
+
+export function buildScore(spec: ScoreSpec): string {
+ const length = (spec.beats * 16 * DIV) / (spec.beatType * 4);
+ const measureCount = spec.parts[0].staves[0].music.split("|").length;
+ const clefLine = { G: 2, F: 4, C: 3 };
+ const parts = spec.parts.map((part) => {
+ const staves = part.staves.map((staff) => staff.music.split("|"));
+ if (staves.some((lines) => lines.length !== measureCount)) throw new Error(`${part.name} 的小节数与其他声部不同。`);
+ let xml = ``;
+ for (let m = 0; m < measureCount; m++) {
+ xml += ``;
+ if (spec.repeats?.forward?.includes(m + 1)) xml += 'heavy-light';
+ if (m === 0) {
+ xml += `${DIV}${spec.fifths}`;
+ if (part.staves.length > 1) xml += `${part.staves.length}`;
+ part.staves.forEach((staff, i) => {
+ xml += ` 1 ? ` number="${i + 1}"` : ""}>${staff.clef}${clefLine[staff.clef]}`;
+ });
+ xml += "";
+ if (spec.tempo && part === spec.parts[0])
+ xml += `${escape(spec.tempo.words)}quarter${spec.tempo.bpm}`;
+ }
+ const dynamic = spec.dynamics?.[m + 1];
+ part.staves.forEach((_, i) => {
+ if (i > 0) xml += `${length}`;
+ const music = dynamic && i === 0 && !staves[i][m].includes("!") ? `!${dynamic} ${staves[i][m]}` : staves[i][m];
+ xml += staffMeasure(music, i + 1, m + 1, length);
+ });
+ if (spec.repeats?.backward?.includes(m + 1))
+ xml += 'light-heavy';
+ else if (m === measureCount - 1) xml += 'light-heavy';
+ xml += "";
+ }
+ return xml + "";
+ });
+ const partList = spec.parts
+ .map(
+ (part) =>
+ `${escape(part.name)}${escape(part.abbreviation)}` +
+ (part.program ? `${escape(part.name)}${part.program}` : "") +
+ "",
+ )
+ .join("");
+ return (
+ '\n' +
+ '\n' +
+ `${escape(spec.title)}` +
+ (spec.subtitle ? `${escape(spec.subtitle)}` : "") +
+ `${escape(spec.composer)}` +
+ (spec.arranger ? `${escape(spec.arranger)}` : "") +
+ `${escape(spec.rights)}Floatboat score builder` +
+ `${partList}${parts.join("")}\n`
+ );
+}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/seed.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/seed.ts
deleted file mode 100644
index f57428d..0000000
--- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/seed.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { bytesToBase64, type ScoreProject } from "./domain.ts";
-const lines = [
- ["C", "E", "G", "E"],
- ["D", "F", "A", "F"],
- ["E", "G", "B", "G"],
- ["F", "A", "C", "A"],
- ["G", "B", "D", "B"],
- ["A", "C", "E", "C"],
- ["G", "F", "D", "B"],
- ["C", "E", "G", "C"],
-];
-const part = (id: string, low: boolean) =>
- `${lines.map((steps, index) => `${index === 0 ? `10${low ? "F" : "G"}${low ? 4 : 2}` : ""}${(low ? [steps[0]] : steps).map((step) => `${step}${low ? 3 : 5}${low ? 4 : 1}${low ? "whole" : "quarter"}`).join("")}${index === 7 ? 'light-heavy' : ""}`).join("")}`;
-export const seedXml = `
-Morning LinesFloatboat original studyOriginal demonstration score; reusable under the repository license.FluteFl.CelloVc.${part("P1", false)}${part("P2", true)}`;
-export const seed: ScoreProject = {
- source: {
- fileName: "morning-lines.musicxml",
- kind: "xml",
- originalBase64: bytesToBase64(new TextEncoder().encode(seedXml)),
- xml: seedXml,
- },
- partId: "",
- layout: "page",
- zoom: 0.85,
- annotations: [],
- focus: { partId: "P1", measureIndex: 0 },
-};
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/sha256.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/sha256.ts
new file mode 100644
index 0000000..c22088b
--- /dev/null
+++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/sha256.ts
@@ -0,0 +1,52 @@
+// ABOUTME: 同步 SHA-256(FIPS 180-4),用于在浏览器中为内置示例原件生成与服务端相同的内容标识。
+const K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+]);
+
+export function sha256Hex(data: Uint8Array): string {
+ const length = data.length;
+ const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);
+ padded.set(data);
+ padded[length] = 0x80;
+ const view = new DataView(padded.buffer);
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
+ view.setUint32(padded.length - 4, (length << 3) >>> 0, false);
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
+ const w = new Uint32Array(64);
+ const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
+ for (let offset = 0; offset < padded.length; offset += 64) {
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false);
+ for (let i = 16; i < 64; i++) {
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
+ }
+ let [a, b, c, d, e, f, g, hh] = h;
+ for (let i = 0; i < 64; i++) {
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
+ hh = g;
+ g = f;
+ f = e;
+ e = (d + t1) >>> 0;
+ d = c;
+ c = b;
+ b = a;
+ a = (t1 + t2) >>> 0;
+ }
+ h[0] += a;
+ h[1] += b;
+ h[2] += c;
+ h[3] += d;
+ h[4] += e;
+ h[5] += f;
+ h[6] += g;
+ h[7] += hh;
+ }
+ return Array.from(h, (x) => x.toString(16).padStart(8, "0")).join("");
+}
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx b/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx
index 8a47ea3..a27b2b7 100644
--- a/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx
+++ b/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx
@@ -1,95 +1,288 @@
-import { memo, useEffect, useRef, useState } from "react";
+// ABOUTME: 谱面:持有 OSMD 实例,负责排版、光标、点击定位、批注图层与回放;通过 ref 暴露给工作台。
+import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState, type KeyboardEvent } from "react";
import type { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
-import { configure, loadEngine, serializeScore } from "./engine";
-import type { ScoreProject } from "../contracts/domain";
-export const ScoreView = memo(function ScoreView({
- xml,
- partId,
- layout,
- zoom,
- onOutput,
-}: {
+import type { Beat, ViewSettings } from "../contracts/domain";
+import {
+ configure,
+ cursorPosition,
+ drawOverlay,
+ hitTest,
+ loadEngine,
+ moveCursorTo,
+ snapshotPages,
+ toSvgPoint,
+ type Hit,
+ type OverlayModel,
+ type PageSnapshot,
+} from "./engine";
+import { Player } from "./playback";
+
+export interface ScoreHandle {
+ step(direction: 1 | -1): void;
+ stepMeasure(direction: 1 | -1): void;
+ togglePlay(): Promise;
+ stop(): void;
+ snapshot(): PageSnapshot[];
+ currentPage(): number;
+ revealFocus(): void;
+}
+export interface RenderState {
+ phase: "loading" | "ready" | "error";
+ message: string;
+ pages: number;
+}
+interface Props {
xml: string;
- partId: string;
- layout: ScoreProject["layout"];
- zoom: number;
- onOutput: (svg: string) => void;
-}) {
- const host = useRef(null),
- instance = useRef(null),
- output = useRef(onOutput),
- settings = useRef({ partId, layout, zoom });
- output.current = onOutput;
- settings.current = { partId, layout, zoom };
- const [ready, setReady] = useState(false),
- [error, setError] = useState("");
- useEffect(() => {
- let active = true;
- setReady(false);
- setError("");
- output.current("");
- if (!host.current) return;
- const container = document.createElement("div");
- host.current.replaceChildren(container);
- let owned: OpenSheetMusicDisplay | undefined;
- void loadEngine()
- .then(async ({ OpenSheetMusicDisplay }) => {
- if (!active) return;
- const engine = new OpenSheetMusicDisplay(container, {
- backend: "svg",
- autoResize: false,
- });
- owned = engine;
- instance.current = engine;
- engine.setLogLevel("error");
- await engine.load(xml);
- if (!active) {
- engine.clear();
- return;
+ view: ViewSettings;
+ overlay: OverlayModel;
+ onPick: (hit: Hit) => void;
+ onMarker: (id: string) => void;
+ onCursor: (position: { measureIndex: number; beat: Beat }) => void;
+ onPlayhead: (position: { measureIndex: number; beat: Beat } | null) => void;
+ onRender: (state: RenderState) => void;
+}
+
+const motion = () => (matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth");
+const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));
+
+export const ScoreView = memo(
+ forwardRef(function ScoreView({ xml, view, overlay, onPick, onMarker, onCursor, onPlayhead, onRender }, ref) {
+ const host = useRef(null);
+ const engine = useRef(null);
+ const player = useRef(null);
+ const latest = useRef({ view, overlay, onPick, onMarker, onCursor, onPlayhead, onRender });
+ latest.current = { view, overlay, onPick, onMarker, onCursor, onPlayhead, onRender };
+ const [ready, setReady] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const viewKey = JSON.stringify(view);
+ const rendered = useRef("");
+
+ const paint = () => {
+ const instance = engine.current;
+ if (instance && host.current) drawOverlay(host.current, instance, latest.current.overlay);
+ };
+ const placeCursor = () => {
+ const instance = engine.current;
+ if (!instance?.cursor) return;
+ instance.cursor.show();
+ const { focus } = latest.current.overlay;
+ moveCursorTo(instance, focus.measureIndex, focus.beat);
+ };
+ const report = (phase: RenderState["phase"], message: string) =>
+ latest.current.onRender({ phase, message, pages: host.current?.querySelectorAll("svg").length ?? 0 });
+
+ // 原件变化:新建实例并完整排版。
+ useEffect(() => {
+ let active = true;
+ setReady(0);
+ if (!host.current) return;
+ const container = document.createElement("div");
+ container.className = "osmd-root";
+ host.current.replaceChildren(container);
+ let owned: OpenSheetMusicDisplay | undefined;
+ report("loading", "正在载入乐谱引擎…");
+ void (async () => {
+ try {
+ const { OpenSheetMusicDisplay } = await loadEngine();
+ if (!active) return;
+ owned = new OpenSheetMusicDisplay(container, { backend: "svg", autoResize: false });
+ engine.current = owned;
+ owned.setLogLevel("error");
+ report("loading", "正在解析 MusicXML…");
+ await nextFrame();
+ await owned.load(xml);
+ if (!active) return;
+ report("loading", "正在排版…");
+ await nextFrame();
+ if (!active) return;
+ await configure(owned, latest.current.view);
+ owned.render();
+ rendered.current = JSON.stringify(latest.current.view);
+ placeCursor();
+ paint();
+ setReady((value) => value + 1);
+ report("ready", "");
+ } catch (reason) {
+ if (active) report("error", reason instanceof Error && reason.message ? `乐谱无法渲染:${reason.message}` : "乐谱无法渲染,请检查文件。");
}
- configure(engine, settings.current);
- engine.render();
- output.current(serializeScore(container));
- setReady(true);
- })
- .catch((reason) => {
- if (active) {
- setError(
- reason instanceof Error
- ? reason.message
- : "乐谱渲染失败,请检查文件。",
- );
- output.current("");
+ })();
+ return () => {
+ active = false;
+ player.current?.dispose();
+ player.current = null;
+ setPlaying(false);
+ owned?.clear();
+ container.remove();
+ if (engine.current === owned) engine.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [xml]);
+
+ // 显示设置变化:重排同一实例。
+ useEffect(() => {
+ const instance = engine.current;
+ if (!ready || !instance || rendered.current === viewKey) return;
+ rendered.current = viewKey;
+ let active = true;
+ player.current?.stop();
+ setPlaying(false);
+ report("loading", "正在重新排版…");
+ void (async () => {
+ await nextFrame();
+ if (!active) return;
+ try {
+ await configure(instance, latest.current.view);
+ instance.render();
+ placeCursor();
+ paint();
+ report("ready", "");
+ } catch (reason) {
+ report("error", reason instanceof Error ? `版式更新失败:${reason.message}` : "版式更新失败。");
}
- });
- return () => {
- active = false;
- owned?.clear();
- container.remove();
- if (instance.current === owned) instance.current = null;
+ })();
+ return () => {
+ active = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [viewKey, ready]);
+
+ // 批注或当前位置变化:只重绘批注层;不在回放中时把光标带到当前位置。
+ useEffect(() => {
+ if (!ready) return;
+ paint();
+ if (!player.current?.playing) placeCursor();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [overlay, ready]);
+
+ const cursorMoved = () => {
+ const instance = engine.current;
+ if (!instance) return;
+ const position = cursorPosition(instance);
+ if (position) latest.current.onCursor(position);
};
- }, [xml]);
- useEffect(() => {
- if (!ready || !instance.current || !host.current) return;
- try {
- configure(instance.current, { partId, layout, zoom });
- instance.current.render();
- output.current(serializeScore(host.current));
- setError("");
- } catch (reason) {
- setError(reason instanceof Error ? reason.message : "版式更新失败。");
- output.current("");
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ step(direction) {
+ const instance = engine.current;
+ if (!instance?.cursor) return;
+ instance.cursor.show();
+ if (direction > 0) instance.cursor.next();
+ else instance.cursor.previous();
+ if (instance.cursor.Iterator.EndReached) instance.cursor.previous();
+ cursorMoved();
+ },
+ stepMeasure(direction) {
+ const instance = engine.current;
+ if (!instance?.cursor) return;
+ const position = cursorPosition(instance);
+ const target = Math.max(0, Math.min(instance.Sheet.SourceMeasures.length - 1, (position?.measureIndex ?? 0) + direction));
+ moveCursorTo(instance, target, null);
+ cursorMoved();
+ },
+ async togglePlay() {
+ const instance = engine.current;
+ if (!instance) return;
+ if (player.current?.playing) {
+ player.current.stop();
+ setPlaying(false);
+ cursorMoved();
+ latest.current.onPlayhead(null);
+ return;
+ }
+ player.current ??= new Player(instance, {
+ tempo: () => latest.current.view.tempo,
+ transpose: () => latest.current.view.transpose,
+ hidden: () => latest.current.view.hiddenParts,
+ onStep: () => {
+ const position = cursorPosition(instance);
+ latest.current.onPlayhead(position);
+ },
+ onEnd: () => {
+ setPlaying(false);
+ latest.current.onPlayhead(null);
+ instance.cursor.previous();
+ cursorMoved();
+ },
+ });
+ setPlaying(true);
+ try {
+ await player.current.start();
+ latest.current.onPlayhead(cursorPosition(instance));
+ } catch {
+ setPlaying(false);
+ report("error", "浏览器拒绝了音频输出,请检查系统声音设置后重试。");
+ }
+ },
+ stop() {
+ if (player.current?.playing) {
+ player.current.stop();
+ setPlaying(false);
+ latest.current.onPlayhead(null);
+ cursorMoved();
+ }
+ },
+ snapshot() {
+ const instance = engine.current;
+ if (!host.current || !instance) throw new Error("请等待乐谱渲染完成。");
+ // 导出不带“选中”高亮:临时按未选中重绘批注层,快照后恢复。
+ drawOverlay(host.current, instance, { ...latest.current.overlay, selectedId: null });
+ try {
+ return snapshotPages(host.current);
+ } finally {
+ paint();
+ }
+ },
+ currentPage() {
+ const pages = Array.from(host.current?.querySelectorAll("svg") ?? []);
+ const viewport = host.current?.closest(".paper-scroll")?.getBoundingClientRect();
+ if (!viewport) return 0;
+ let best = 0,
+ area = -1;
+ pages.forEach((page, i) => {
+ const rect = page.getBoundingClientRect();
+ const w = Math.max(0, Math.min(rect.right, viewport.right) - Math.max(rect.left, viewport.left));
+ const h = Math.max(0, Math.min(rect.bottom, viewport.bottom) - Math.max(rect.top, viewport.top));
+ if (w * h > area) {
+ area = w * h;
+ best = i;
+ }
+ });
+ return best;
+ },
+ revealFocus() {
+ host.current?.querySelector(".fb-focus")?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: motion() });
+ },
+ }),
+ [],
+ );
+
+ function click(event: React.MouseEvent) {
+ const target = event.target as Element;
+ const mark = target.closest(".fb-mark");
+ if (mark) {
+ latest.current.onMarker(mark.getAttribute("data-id") ?? "");
+ return;
+ }
+ const svg = target.closest("svg");
+ const instance = engine.current;
+ if (!svg || !instance || !host.current) return;
+ const page = Array.from(host.current.querySelectorAll("svg")).indexOf(svg as SVGSVGElement);
+ const point = toSvgPoint(svg as SVGSVGElement, event.clientX, event.clientY);
+ if (page < 0 || !point) return;
+ const hit = hitTest(instance, page, point.x, point.y);
+ if (hit) latest.current.onPick(hit);
+ }
+ function key(event: KeyboardEvent) {
+ const mark = (event.target as Element).closest?.(".fb-mark");
+ if (mark && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ latest.current.onMarker(mark.getAttribute("data-id") ?? "");
+ }
}
- }, [partId, layout, zoom, ready]);
- return (
- <>
-
- {error || (!ready ? "正在排版乐谱…" : "乐谱已更新")}
-
-
- >
- );
-});
+ return (
+
+ );
+ }),
+);
diff --git a/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts b/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts
index ba5a777..4f089b6 100644
--- a/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts
+++ b/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts
@@ -1,5 +1,7 @@
-import type { OpenSheetMusicDisplay } from "opensheetmusicdisplay";
-import type { ScoreProject } from "../contracts/domain";
+// ABOUTME: OpenSheetMusicDisplay 的配置、几何查询(小节/拍位/点击命中)、光标定位、批注图层与导出用的页面快照。
+import type { GraphicalMeasure, IOSMDOptions, OpenSheetMusicDisplay, SourceMeasure } from "opensheetmusicdisplay";
+import type { Beat, Focus, ViewSettings } from "../contracts/domain";
+
let pending: Promise | undefined;
export function loadEngine() {
return (pending ??= import("opensheetmusicdisplay").catch((error) => {
@@ -7,79 +9,301 @@ export function loadEngine() {
throw error;
}));
}
-export function configure(
- engine: OpenSheetMusicDisplay,
- settings: Pick,
-) {
- engine.setOptions({
- pageFormat: settings.layout === "page" ? "A4_P" : "Endless",
+
+/** OSMD 单位到 SVG 用户坐标:VexFlow SVG 后端以 10 个用户单位表示 1 个 OSMD 单位。 */
+const UNIT = 10;
+const SVG_NS = "http://www.w3.org/2000/svg";
+export const ACCENT = "#c2410c";
+const RESOLVED = "#5b6b7d";
+const SELECTED = "#1d4ed8";
+
+export function engineOptions(view: ViewSettings): IOSMDOptions {
+ return {
+ backend: "svg",
autoResize: false,
- drawTitle: true,
- drawComposer: true,
- drawMeasureNumbers: true,
+ autoBeam: true,
+ pageFormat: view.layout === "page" ? "A4_P" : view.layout === "landscape" ? "A4_L" : "Endless",
+ renderSingleHorizontalStaffline: view.layout === "line",
+ pageBackgroundColor: "#FFFFFF",
+ drawTitle: view.credits,
+ drawSubtitle: view.credits,
+ drawComposer: view.credits,
+ drawLyricist: view.credits,
+ drawCredits: view.credits,
+ drawPartNames: view.partNames,
+ drawPartAbbreviations: view.partNames,
+ drawMeasureNumbers: view.measureNumbers !== "off",
+ drawMeasureNumbersOnlyAtSystemStart: view.measureNumbers === "system",
+ measureNumberInterval: 1,
+ useXMLMeasureNumbers: true,
+ // 保持小节与 MusicXML 一一对应,批注锚点不会落在合并的多小节休止上。
+ autoGenerateMultipleRestMeasuresFromRestMeasures: false,
+ followCursor: true,
+ cursorsOptions: [{ type: 0, color: "#2563eb", alpha: 0.28, follow: true }],
+ };
+}
+
+/** 应用显示设置(移调时重新计算图形);调用方随后 render()。 */
+export async function configure(engine: OpenSheetMusicDisplay, view: ViewSettings): Promise {
+ engine.setOptions(engineOptions(view));
+ engine.Zoom = view.zoom;
+ for (const instrument of engine.Sheet.Instruments) instrument.Visible = !view.hiddenParts.includes(instrument.IdString);
+ if (engine.Sheet.Transpose !== view.transpose) {
+ if (!engine.TransposeCalculator) {
+ const { TransposeCalculator } = await loadEngine();
+ engine.TransposeCalculator = new TransposeCalculator();
+ }
+ engine.Sheet.Transpose = view.transpose;
+ engine.updateGraphic();
+ }
+}
+
+export interface Box {
+ page: number;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+function measureIndexMap(engine: OpenSheetMusicDisplay): Map {
+ const map = new Map();
+ engine.Sheet.SourceMeasures.forEach((measure, index) => map.set(measure, index));
+ return map;
+}
+const pageOf = (engine: OpenSheetMusicDisplay, gm: GraphicalMeasure) => engine.GraphicSheet.MusicPages.indexOf(gm.ParentMusicSystem?.Parent);
+const partOf = (gm: GraphicalMeasure | undefined) => gm?.ParentStaff?.ParentInstrument?.IdString;
+
+function partMeasures(engine: OpenSheetMusicDisplay, partId: string, measureIndex: number): GraphicalMeasure[] {
+ const source = engine.Sheet.SourceMeasures[measureIndex];
+ if (!source) return [];
+ const list = engine.GraphicSheet.MeasureList;
+ const pick = (row: GraphicalMeasure[]) => row.filter((gm) => gm && partOf(gm) === partId && gm.ParentMusicSystem);
+ const direct = list[measureIndex];
+ if (direct?.find(Boolean)?.parentSourceMeasure === source) return pick(direct);
+ for (const row of list) if (row.find(Boolean)?.parentSourceMeasure === source) return pick(row);
+ return [];
+}
+
+/** 某声部某小节所有谱表的外框(SVG 用户坐标)。 */
+export function measureBox(engine: OpenSheetMusicDisplay, partId: string, measureIndex: number): Box | null {
+ const staves = partMeasures(engine, partId, measureIndex);
+ if (!staves.length) return null;
+ const first = staves[0].PositionAndShape;
+ const top = Math.min(...staves.map((gm) => gm.PositionAndShape.AbsolutePosition.y));
+ const bottom = Math.max(...staves.map((gm) => gm.PositionAndShape.AbsolutePosition.y + gm.PositionAndShape.Size.height));
+ return { page: pageOf(engine, staves[0]), x: first.AbsolutePosition.x * UNIT, y: top * UNIT, w: first.Size.width * UNIT, h: (bottom - top) * UNIT };
+}
+
+const sameBeat = (a: { Numerator: number; Denominator: number }, beat: [number, number]) => a.Numerator * beat[1] === beat[0] * a.Denominator;
+
+/** 拍位的横坐标:取该声部第一谱表上与拍位相同的音符位置;没有则取最近的。 */
+export function beatX(engine: OpenSheetMusicDisplay, partId: string, measureIndex: number, beat: Beat): number | null {
+ if (!beat) return null;
+ const gm = partMeasures(engine, partId, measureIndex)[0];
+ if (!gm?.staffEntries.length) return null;
+ const target = beat[0] / beat[1];
+ let best = gm.staffEntries[0];
+ for (const entry of gm.staffEntries) {
+ if (sameBeat(entry.relInMeasureTimestamp, beat)) return entry.PositionAndShape.AbsolutePosition.x * UNIT;
+ if (Math.abs(entry.relInMeasureTimestamp.RealValue - target) < Math.abs(best.relInMeasureTimestamp.RealValue - target)) best = entry;
+ }
+ return best.PositionAndShape.AbsolutePosition.x * UNIT;
+}
+
+export interface Hit extends Focus {
+ /** 点击是否落在音符附近(用于默认按拍位锚定)。 */
+ nearNote: boolean;
+}
+/** 页面 SVG 用户坐标 → 最近的小节、声部与拍位。 */
+export function hitTest(engine: OpenSheetMusicDisplay, page: number, x: number, y: number): Hit | null {
+ const ux = x / UNIT,
+ uy = y / UNIT;
+ const indexes = measureIndexMap(engine);
+ let best: { gm: GraphicalMeasure; distance: number } | null = null;
+ for (const row of engine.GraphicSheet.MeasureList)
+ for (const gm of row) {
+ if (!gm?.ParentMusicSystem || pageOf(engine, gm) !== page) continue;
+ const box = gm.PositionAndShape;
+ const left = box.AbsolutePosition.x,
+ top = box.AbsolutePosition.y;
+ if (ux < left || ux > left + box.Size.width) continue;
+ const distance = uy < top ? top - uy : uy > top + box.Size.height ? uy - top - box.Size.height : 0;
+ if (distance > 6) continue;
+ if (!best || distance < best.distance) best = { gm, distance };
+ }
+ if (!best) return null;
+ const { gm } = best;
+ const partId = partOf(gm);
+ const measureIndex = indexes.get(gm.parentSourceMeasure);
+ if (!partId || measureIndex === undefined) return null;
+ let beat: Beat = null,
+ nearNote = false;
+ if (gm.staffEntries.length) {
+ let entry = gm.staffEntries[0];
+ for (const candidate of gm.staffEntries)
+ if (Math.abs(candidate.PositionAndShape.AbsolutePosition.x - ux) < Math.abs(entry.PositionAndShape.AbsolutePosition.x - ux)) entry = candidate;
+ beat = [entry.relInMeasureTimestamp.Numerator, entry.relInMeasureTimestamp.Denominator];
+ nearNote = Math.abs(entry.PositionAndShape.AbsolutePosition.x - ux) < 2.5;
+ }
+ return { partId, measureIndex, beat, nearNote };
+}
+
+/** 光标所在小节与拍位。 */
+export function cursorPosition(engine: OpenSheetMusicDisplay): { measureIndex: number; beat: [number, number] } | null {
+ const iterator = engine.cursor?.Iterator;
+ if (!iterator || iterator.EndReached) return null;
+ const rel = iterator.CurrentRelativeInMeasureTimestamp;
+ return { measureIndex: iterator.CurrentMeasureIndex, beat: [rel.Numerator, rel.Denominator] };
+}
+
+/** 把光标移到指定小节的拍位(或小节开头);最多迭代 200 000 步。 */
+export function moveCursorTo(engine: OpenSheetMusicDisplay, measureIndex: number, beat: Beat): void {
+ const cursor = engine.cursor;
+ if (!cursor) return;
+ const target = beat ? beat[0] / beat[1] : 0;
+ const before = () => {
+ const it = cursor.Iterator;
+ return it.CurrentMeasureIndex < measureIndex || (it.CurrentMeasureIndex === measureIndex && it.CurrentRelativeInMeasureTimestamp.RealValue < target - 1e-9);
+ };
+ const position = cursorPosition(engine);
+ if (!position || position.measureIndex > measureIndex || (position.measureIndex === measureIndex && position.beat[0] / position.beat[1] > target + 1e-9))
+ cursor.reset();
+ for (let step = 0; step < 200_000 && !cursor.Iterator.EndReached && before(); step++) cursor.next();
+ if (cursor.Iterator.EndReached) cursor.previous();
+}
+
+export interface Marker {
+ id: string;
+ number: number;
+ partId: string;
+ measureIndex: number;
+ beat: Beat;
+ resolved: boolean;
+ label: string;
+}
+export interface OverlayModel {
+ focus: Focus;
+ markers: Marker[];
+ selectedId: string | null;
+}
+
+const el = (name: string, attrs: Record) => {
+ const node = document.createElementNS(SVG_NS, name);
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
+ return node;
+};
+
+/** 在每页 SVG 内绘制批注层:当前位置、编号批注标记。坐标与谱面一致,随缩放与导出保留。 */
+export function drawOverlay(container: HTMLElement, engine: OpenSheetMusicDisplay, model: OverlayModel): void {
+ const pages = Array.from(container.querySelectorAll("svg"));
+ for (const svg of pages) svg.querySelector(":scope > g.fb-overlay")?.remove();
+ const layers = pages.map((svg) => {
+ const g = el("g", { class: "fb-overlay" });
+ svg.append(g);
+ return g;
});
- engine.Zoom = settings.zoom;
- for (const instrument of engine.Sheet.Instruments)
- instrument.Visible =
- !settings.partId || instrument.IdString === settings.partId;
+ const focusBox = measureBox(engine, model.focus.partId, model.focus.measureIndex);
+ if (focusBox && layers[focusBox.page]) {
+ const layer = layers[focusBox.page];
+ layer.append(el("rect", { class: "fb-focus", x: focusBox.x - 4, y: focusBox.y - 12, width: focusBox.w + 8, height: focusBox.h + 24, rx: 6, fill: ACCENT, "fill-opacity": 0.07, stroke: ACCENT, "stroke-opacity": 0.55, "stroke-width": 2 }));
+ const x = beatX(engine, model.focus.partId, model.focus.measureIndex, model.focus.beat);
+ if (x !== null) layer.append(el("line", { class: "fb-focus-beat", x1: x + 6, x2: x + 6, y1: focusBox.y - 14, y2: focusBox.y + focusBox.h + 14, stroke: ACCENT, "stroke-width": 3, "stroke-linecap": "round" }));
+ }
+ const taken = new Map();
+ for (const marker of model.markers) {
+ const box = measureBox(engine, marker.partId, marker.measureIndex);
+ if (!box || !layers[box.page]) continue;
+ const color = marker.id === model.selectedId ? SELECTED : marker.resolved ? RESOLVED : ACCENT;
+ const beat = beatX(engine, marker.partId, marker.measureIndex, marker.beat);
+ const baseX = beat !== null ? beat + 6 : box.x + 14;
+ const key = `${box.page}:${Math.round(baseX)}:${Math.round(box.y)}`;
+ const stack = taken.get(key) ?? 0;
+ taken.set(key, stack + 1);
+ const cx = baseX + stack * 26,
+ cy = box.y - 34;
+ const group = el("g", { class: "fb-mark", "data-id": marker.id, tabindex: 0, role: "button", "aria-label": marker.label });
+ const title = el("title", {});
+ title.textContent = marker.label;
+ group.append(title);
+ if (beat === null)
+ group.append(el("path", { d: `M${box.x} ${box.y - 12} v-6 h${box.w} v6`, fill: "none", stroke: color, "stroke-width": 2, "stroke-opacity": 0.7 }));
+ else group.append(el("line", { x1: cx, x2: cx, y1: cy + 11, y2: box.y - 4, stroke: color, "stroke-width": 2 }));
+ group.append(el("circle", { cx, cy, r: 12, fill: color, stroke: "#ffffff", "stroke-width": marker.id === model.selectedId ? 3 : 1.5 }));
+ const label = el("text", { x: cx, y: cy + 4.5, "text-anchor": "middle", "font-size": 13, "font-weight": 700, "font-family": "Helvetica, Arial, sans-serif", fill: "#ffffff" });
+ label.textContent = String(marker.number);
+ group.append(label);
+ layers[box.page].append(group);
+ }
+}
+
+/** 某页 SVG 的客户端坐标 → 用户坐标。 */
+export function toSvgPoint(svg: SVGSVGElement, clientX: number, clientY: number): { x: number; y: number } | null {
+ const matrix = svg.getScreenCTM();
+ if (!matrix) return null;
+ const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse());
+ return { x: point.x, y: point.y };
+}
+
+export interface PageSnapshot {
+ svg: string;
+ width: number;
+ height: number;
+}
+/** 复制当前全部谱页(含批注层),白底、无工作台样式依赖。 */
+export function snapshotPages(container: HTMLElement): PageSnapshot[] {
+ const pages = Array.from(container.querySelectorAll("svg"));
+ if (!pages.length) throw new Error("请等待乐谱渲染完成。");
+ return pages.map((page) => {
+ const box = page.viewBox.baseVal;
+ const width = box?.width || Number.parseFloat(page.getAttribute("width") || "0");
+ const height = box?.height || Number.parseFloat(page.getAttribute("height") || "0");
+ if (!width || !height) throw new Error("乐谱页面尺寸无效。");
+ const copy = page.cloneNode(true) as SVGSVGElement;
+ copy.querySelectorAll(".fb-focus, .fb-focus-beat").forEach((node) => node.remove());
+ copy.setAttribute("xmlns", SVG_NS);
+ copy.setAttribute("width", String(width));
+ copy.setAttribute("height", String(height));
+ copy.setAttribute("viewBox", `${box?.x ?? 0} ${box?.y ?? 0} ${width} ${height}`);
+ copy.removeAttribute("style");
+ copy.prepend(el("rect", { x: box?.x ?? 0, y: box?.y ?? 0, width, height, fill: "#ffffff" }));
+ return { svg: new XMLSerializer().serializeToString(copy), width, height };
+ });
+}
+
+/** 把多页纵向拼成一个独立 SVG 文件。 */
+export function stackSvg(pages: PageSnapshot[]): string {
+ const gap = 24;
+ const width = Math.max(...pages.map((page) => page.width));
+ const height = pages.reduce((sum, page) => sum + page.height, 0) + gap * (pages.length - 1);
+ let y = 0;
+ const body = pages
+ .map((page) => {
+ const nested = page.svg.replace(/^