commit cacbabea8531f7b867bb9c7dcf53589d9432bfe3 Author: Rogee Date: Tue Sep 22 16:33:35 2026 +0800 feat: publish JPW7 web editor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16d8bd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +.DS_Store +*.log +.vscode/ + +# Generated evidence is kept outside the source baseline unless explicitly committed. +acceptance/runs/ + +# Local agent state and machine-specific MCP configuration. +.pi/ +.codegraph/ +.mcp.json +AGENTS.md + +# Private/non-redistributable research inputs and local acceptance artifacts. +research/ +acceptance/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..badc9f3 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# JP Editor + +JPW7-compatible Web jianpu editor. The editable project format is `.jianpu.json`; `.jpwabc` is compatibility output. + +## Local manual test + +```bash +npm ci +npm run dev +``` + +Then open in Chromium. + +The public checkout intentionally excludes private research inputs and local acceptance artifacts; those are kept in the developer workspace and are covered by `.gitignore`. diff --git a/config/acceptance-environment.json b/config/acceptance-environment.json new file mode 100644 index 0000000..2db4286 --- /dev/null +++ b/config/acceptance-environment.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "baselineId": "jpw7-web-m0", + "lockedAt": "M0", + "sourceOfTruth": "This file fixes the profile; each acceptance run must add exact measured versions to its evidence record.", + "buildEnvironment": { + "os": "Linux workspace (developer build)", + "node": "22.21.0", + "npm": "11.6.4", + "typescript": "5.9.3", + "python": "3.13.5", + "git": "2.47.3" + }, + "primaryAcceptanceProfile": { + "machine": "Windows 11 x64 desktop", + "interaction": "Windows UI automation or approved SSH fallback", + "browserProfiles": [ + { "id": "chrome-stable-primary", "browser": "Google Chrome Stable", "versionPolicy": "exact-version-required-in-run-evidence" }, + { "id": "edge-stable-secondary", "browser": "Microsoft Edge Stable", "versionPolicy": "exact-version-required-in-run-evidence" }, + { "id": "firefox-secondary", "browser": "Mozilla Firefox current stable", "versionPolicy": "exact-version-required-in-run-evidence" } + ], + "fontProfile": ["Noto Sans", "Noto Sans CJK SC", "browser-default-fallback-only-when-diagnosed"], + "paperProfile": { "size": "A4", "units": "mm", "orientation": "portrait", "printScale": "100%", "margins": "fixed-by-layout-contract" }, + "audioProfile": { "baseProbe": "Web Audio API oscillator", "externalSoundfont": false }, + "captureRequirements": ["browser-and-version", "os-and-version", "font-file-and-hash", "paper-printer-settings", "audio-device-or-event-capture"] + }, + "status": "profile-locked; measured run evidence required before R0/R1 release" +} diff --git a/config/original-format-write-set.json b/config/original-format-write-set.json new file mode 100644 index 0000000..adc9441 --- /dev/null +++ b/config/original-format-write-set.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "format": ".jpwabc", + "policy": "allow-list-only", + "defaultWrite": "blocked", + "unknownContent": "preserve-and-block-unsafe-export", + "externalReplayRequired": true, + "entries": [ + { "id": "core.options", "section": ".Options", "writeStatus": "planned", "phase": "M5", "requires": ["known-key-mapping", "ACC-X-012"] }, + { "id": "core.fonts", "section": ".Fonts", "writeStatus": "planned", "phase": "M5", "requires": ["known-role-mapping", "ACC-X-012"] }, + { "id": "core.title", "section": ".Title", "writeStatus": "planned", "phase": "M5", "requires": ["known-field-mapping", "ACC-X-012"] }, + { "id": "core.voice", "section": ".Voice", "writeStatus": "planned", "phase": "M5", "requires": ["known-field-mapping", "ACC-X-012"] }, + { "id": "core.words", "section": ".Words", "writeStatus": "planned", "phase": "M5", "requires": ["known-field-mapping", "ACC-X-012"] }, + { "id": "core.attachments", "section": ".Attachments", "writeStatus": "planned", "phase": "M6", "requires": ["attachment-safety", "ACC-X-013"] }, + { "id": "page.objects", "section": ".Page", "writeStatus": "blocked-until-verified", "phase": "M7", "requires": ["stable-page-mapping", "ACC-X-013"] }, + { "id": "unknown.sections-and-tokens", "section": "unknown", "writeStatus": "blocked", "phase": "M1+", "requires": ["lossless-preservation", "safe-export-preflight"] } + ] +} diff --git a/config/requirements.json b/config/requirements.json new file mode 100644 index 0000000..c96796b --- /dev/null +++ b/config/requirements.json @@ -0,0 +1,1096 @@ +{ + "version": 1, + "generatedBy": "scripts/generate-requirements.mjs", + "sources": [ + { + "path": "docs/JPW7在线简谱编辑器-产品需求文档.md", + "version": "current project baseline" + }, + { + "path": "docs/JPW7在线简谱编辑器-功能规格与技术方案.md", + "version": "current project baseline" + }, + { + "path": "docs/JPW7在线简谱编辑器-开发步骤计划.md", + "version": "v1.1" + }, + { + "path": "docs/JPW7在线简谱编辑器-验收标准.md", + "version": "v1.2" + }, + { + "path": "docs/JPW7在线简谱编辑器-兼容性矩阵.md", + "version": "current project baseline" + } + ], + "capabilityDimensions": [ + "file-codec", + "parser", + "lossless-retention", + "semantic-model", + "stable-anchor", + "commands", + "project-save", + "layout", + "svg", + "playback", + "compatibility-export", + "e2e", + "performance", + "security", + "accessibility", + "licensing", + "evidence" + ], + "entryCount": 61, + "entries": [ + { + "id": "F-001", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-002", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-003", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-004", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-005", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-006", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-007", + "source": "PRD-F-001..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M1", + "M2", + "M4", + "M5" + ], + "accRefs": [ + "ACC-F-001..020", + "ACC-X-001..012" + ], + "status": "baseline-not-started", + "notes": "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" + }, + { + "id": "F-008", + "source": "PRD-F-008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M2", + "M4", + "M6", + "M7" + ], + "accRefs": [ + "ACC-F-021..022", + "ACC-L-009..011", + "ACC-L-019", + "ACC-X-013" + ], + "status": "baseline-not-started", + "notes": "扩展编辑能力,未知内容必须保留并诊断。" + }, + { + "id": "E-001", + "source": "PRD-E-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M2", + "M4", + "M6" + ], + "accRefs": [ + "ACC-E-001..004", + "ACC-E-007", + "ACC-F-009" + ], + "status": "baseline-not-started", + "notes": "核心节拍、音符、休止和时值语义。" + }, + { + "id": "E-002", + "source": "PRD-E-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M2", + "M4", + "M6" + ], + "accRefs": [ + "ACC-E-001..004", + "ACC-E-007", + "ACC-F-009" + ], + "status": "baseline-not-started", + "notes": "核心节拍、音符、休止和时值语义。" + }, + { + "id": "E-003", + "source": "PRD-E-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M2", + "M4", + "M6" + ], + "accRefs": [ + "ACC-E-001..004", + "ACC-E-007", + "ACC-F-009" + ], + "status": "baseline-not-started", + "notes": "核心节拍、音符、休止和时值语义。" + }, + { + "id": "E-004", + "source": "PRD-E-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M2", + "M4", + "M6" + ], + "accRefs": [ + "ACC-E-001..004", + "ACC-E-007", + "ACC-F-009" + ], + "status": "baseline-not-started", + "notes": "核心节拍、音符、休止和时值语义。" + }, + { + "id": "E-005", + "source": "PRD-E-005..008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M6", + "M8" + ], + "accRefs": [ + "ACC-E-005..012", + "ACC-O-001..006", + "ACC-A-009", + "ACC-A-017", + "ACC-A-021..023" + ], + "status": "baseline-not-started", + "notes": "复杂装饰、和弦/伴奏及扩展语义。" + }, + { + "id": "E-006", + "source": "PRD-E-005..008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M6", + "M8" + ], + "accRefs": [ + "ACC-E-005..012", + "ACC-O-001..006", + "ACC-A-009", + "ACC-A-017", + "ACC-A-021..023" + ], + "status": "baseline-not-started", + "notes": "复杂装饰、和弦/伴奏及扩展语义。" + }, + { + "id": "E-007", + "source": "PRD-E-005..008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M6", + "M8" + ], + "accRefs": [ + "ACC-E-005..012", + "ACC-O-001..006", + "ACC-A-009", + "ACC-A-017", + "ACC-A-021..023" + ], + "status": "baseline-not-started", + "notes": "复杂装饰、和弦/伴奏及扩展语义。" + }, + { + "id": "E-008", + "source": "PRD-E-005..008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M6", + "M8" + ], + "accRefs": [ + "ACC-E-005..012", + "ACC-O-001..006", + "ACC-A-009", + "ACC-A-017", + "ACC-A-021..023" + ], + "status": "baseline-not-started", + "notes": "复杂装饰、和弦/伴奏及扩展语义。" + }, + { + "id": "T-001", + "source": "PRD-T-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M4", + "M6" + ], + "accRefs": [ + "ACC-T-001..007", + "ACC-L-006", + "ACC-L-012" + ], + "status": "baseline-not-started", + "notes": "歌词、分词、对齐和未知歌词内容保留。" + }, + { + "id": "T-002", + "source": "PRD-T-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M4", + "M6" + ], + "accRefs": [ + "ACC-T-001..007", + "ACC-L-006", + "ACC-L-012" + ], + "status": "baseline-not-started", + "notes": "歌词、分词、对齐和未知歌词内容保留。" + }, + { + "id": "T-003", + "source": "PRD-T-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M4", + "M6" + ], + "accRefs": [ + "ACC-T-001..007", + "ACC-L-006", + "ACC-L-012" + ], + "status": "baseline-not-started", + "notes": "歌词、分词、对齐和未知歌词内容保留。" + }, + { + "id": "T-004", + "source": "PRD-T-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M4", + "M6" + ], + "accRefs": [ + "ACC-T-001..007", + "ACC-L-006", + "ACC-L-012" + ], + "status": "baseline-not-started", + "notes": "歌词、分词、对齐和未知歌词内容保留。" + }, + { + "id": "T-005", + "source": "PRD-T-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M4", + "M6" + ], + "accRefs": [ + "ACC-T-001..007", + "ACC-L-006", + "ACC-L-012" + ], + "status": "baseline-not-started", + "notes": "歌词、分词、对齐和未知歌词内容保留。" + }, + { + "id": "T-006", + "source": "PRD-T-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M4", + "M6" + ], + "accRefs": [ + "ACC-T-001..007", + "ACC-L-006", + "ACC-L-012" + ], + "status": "baseline-not-started", + "notes": "歌词、分词、对齐和未知歌词内容保留。" + }, + { + "id": "V-001", + "source": "PRD-V-001..006", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-V-001..006", + "ACC-O-003..006", + "ACC-L-020" + ], + "status": "baseline-not-started", + "notes": "多声部、声部组和临时声部。" + }, + { + "id": "V-002", + "source": "PRD-V-001..006", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-V-001..006", + "ACC-O-003..006", + "ACC-L-020" + ], + "status": "baseline-not-started", + "notes": "多声部、声部组和临时声部。" + }, + { + "id": "V-003", + "source": "PRD-V-001..006", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-V-001..006", + "ACC-O-003..006", + "ACC-L-020" + ], + "status": "baseline-not-started", + "notes": "多声部、声部组和临时声部。" + }, + { + "id": "V-004", + "source": "PRD-V-001..006", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-V-001..006", + "ACC-O-003..006", + "ACC-L-020" + ], + "status": "baseline-not-started", + "notes": "多声部、声部组和临时声部。" + }, + { + "id": "V-005", + "source": "PRD-V-001..006", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-V-001..006", + "ACC-O-003..006", + "ACC-L-020" + ], + "status": "baseline-not-started", + "notes": "多声部、声部组和临时声部。" + }, + { + "id": "V-006", + "source": "PRD-V-001..006", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-V-001..006", + "ACC-O-003..006", + "ACC-L-020" + ], + "status": "baseline-not-started", + "notes": "多声部、声部组和临时声部。" + }, + { + "id": "V-007", + "source": "PRD-V-007", + "priority": "P1", + "scope": "R1", + "stages": [ + "M5", + "M7" + ], + "accRefs": [ + "ACC-V-007", + "ACC-A-015" + ], + "status": "baseline-not-started", + "notes": "声部级播放/静音与导出边界。" + }, + { + "id": "L-001", + "source": "PRD-L-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-001..008", + "ACC-L-013..018", + "ACC-L-020", + "ACC-G-001..004", + "ACC-X-009" + ], + "status": "baseline-not-started", + "notes": "A4 固定布局、分页、字体测量和 SVG 正式谱面。" + }, + { + "id": "L-002", + "source": "PRD-L-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-001..008", + "ACC-L-013..018", + "ACC-L-020", + "ACC-G-001..004", + "ACC-X-009" + ], + "status": "baseline-not-started", + "notes": "A4 固定布局、分页、字体测量和 SVG 正式谱面。" + }, + { + "id": "L-003", + "source": "PRD-L-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-001..008", + "ACC-L-013..018", + "ACC-L-020", + "ACC-G-001..004", + "ACC-X-009" + ], + "status": "baseline-not-started", + "notes": "A4 固定布局、分页、字体测量和 SVG 正式谱面。" + }, + { + "id": "L-004", + "source": "PRD-L-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-001..008", + "ACC-L-013..018", + "ACC-L-020", + "ACC-G-001..004", + "ACC-X-009" + ], + "status": "baseline-not-started", + "notes": "A4 固定布局、分页、字体测量和 SVG 正式谱面。" + }, + { + "id": "L-005", + "source": "PRD-L-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-001..008", + "ACC-L-013..018", + "ACC-L-020", + "ACC-G-001..004", + "ACC-X-009" + ], + "status": "baseline-not-started", + "notes": "A4 固定布局、分页、字体测量和 SVG 正式谱面。" + }, + { + "id": "L-006", + "source": "PRD-L-001..006", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-001..008", + "ACC-L-013..018", + "ACC-L-020", + "ACC-G-001..004", + "ACC-X-009" + ], + "status": "baseline-not-started", + "notes": "A4 固定布局、分页、字体测量和 SVG 正式谱面。" + }, + { + "id": "L-007", + "source": "PRD-L-007..008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-L-009..011", + "ACC-L-019" + ], + "status": "baseline-not-started", + "notes": "手动布局状态必须进入工程保存。" + }, + { + "id": "L-008", + "source": "PRD-L-007..008", + "priority": "P1", + "scope": "R1", + "stages": [ + "M7" + ], + "accRefs": [ + "ACC-L-009..011", + "ACC-L-019" + ], + "status": "baseline-not-started", + "notes": "手动布局状态必须进入工程保存。" + }, + { + "id": "L-009", + "source": "PRD-L-009", + "priority": "P1", + "scope": "R1", + "stages": [ + "M3", + "M6" + ], + "accRefs": [ + "ACC-L-005", + "ACC-L-015", + "ACC-G-001..004" + ], + "status": "baseline-not-started", + "notes": "复杂布局附件和占位行为。" + }, + { + "id": "L-010", + "source": "PRD-L-010", + "priority": "P1", + "scope": "R1", + "stages": [ + "M3" + ], + "accRefs": [ + "ACC-L-018", + "ACC-Q-002" + ], + "status": "baseline-not-started", + "notes": "输出确定性和溢出诊断。" + }, + { + "id": "A-001", + "source": "PRD-A-001..003,006..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M5" + ], + "accRefs": [ + "ACC-A-001..008", + "ACC-A-012..016" + ], + "status": "baseline-not-started", + "notes": "基础播放、控制、时间线和声部控制。" + }, + { + "id": "A-002", + "source": "PRD-A-001..003,006..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M5" + ], + "accRefs": [ + "ACC-A-001..008", + "ACC-A-012..016" + ], + "status": "baseline-not-started", + "notes": "基础播放、控制、时间线和声部控制。" + }, + { + "id": "A-003", + "source": "PRD-A-001..003,006..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M5" + ], + "accRefs": [ + "ACC-A-001..008", + "ACC-A-012..016" + ], + "status": "baseline-not-started", + "notes": "基础播放、控制、时间线和声部控制。" + }, + { + "id": "A-006", + "source": "PRD-A-001..003,006..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M5" + ], + "accRefs": [ + "ACC-A-001..008", + "ACC-A-012..016" + ], + "status": "baseline-not-started", + "notes": "基础播放、控制、时间线和声部控制。" + }, + { + "id": "A-007", + "source": "PRD-A-001..003,006..007", + "priority": "P0", + "scope": "R1", + "stages": [ + "M5" + ], + "accRefs": [ + "ACC-A-001..008", + "ACC-A-012..016" + ], + "status": "baseline-not-started", + "notes": "基础播放、控制、时间线和声部控制。" + }, + { + "id": "A-004", + "source": "PRD-A-004..005", + "priority": "P1", + "scope": "R1", + "stages": [ + "M8" + ], + "accRefs": [ + "ACC-A-009..011", + "ACC-A-017..023" + ], + "status": "baseline-not-started", + "notes": "反复、谱内控制状态和扩展播放降级。" + }, + { + "id": "A-005", + "source": "PRD-A-004..005", + "priority": "P1", + "scope": "R1", + "stages": [ + "M8" + ], + "accRefs": [ + "ACC-A-009..011", + "ACC-A-017..023" + ], + "status": "baseline-not-started", + "notes": "反复、谱内控制状态和扩展播放降级。" + }, + { + "id": "A-008", + "source": "PRD-A-008", + "priority": "P2", + "scope": "out-of-scope", + "stages": [], + "accRefs": [ + "ACC-A-023" + ], + "status": "baseline-not-started", + "notes": "外部音色/扩展音频能力明确不纳入本目标。" + }, + { + "id": "A-009", + "source": "PRD-A-009", + "priority": "P1", + "scope": "R1", + "stages": [ + "M8" + ], + "accRefs": [ + "ACC-A-006", + "ACC-A-018", + "ACC-A-022", + "ACC-E-007", + "ACC-T-007" + ], + "status": "baseline-not-started", + "notes": "W1 播放金样和控制语义。" + }, + { + "id": "U-001", + "source": "PRD-U-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M4", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-004..006", + "ACC-L-013", + "ACC-X-003", + "ACC-X-006" + ], + "status": "baseline-not-started", + "notes": "可用性、键盘、无障碍和错误提示。" + }, + { + "id": "U-002", + "source": "PRD-U-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M4", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-004..006", + "ACC-L-013", + "ACC-X-003", + "ACC-X-006" + ], + "status": "baseline-not-started", + "notes": "可用性、键盘、无障碍和错误提示。" + }, + { + "id": "U-003", + "source": "PRD-U-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M4", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-004..006", + "ACC-L-013", + "ACC-X-003", + "ACC-X-006" + ], + "status": "baseline-not-started", + "notes": "可用性、键盘、无障碍和错误提示。" + }, + { + "id": "U-004", + "source": "PRD-U-001..004", + "priority": "P0", + "scope": "R1", + "stages": [ + "M4", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-004..006", + "ACC-L-013", + "ACC-X-003", + "ACC-X-006" + ], + "status": "baseline-not-started", + "notes": "可用性、键盘、无障碍和错误提示。" + }, + { + "id": "Q-001", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-002", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-003", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-004", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-005", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-006", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-007", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-008", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + }, + { + "id": "Q-009", + "source": "验收标准-质量门槛", + "priority": "P0/P1", + "scope": "R1", + "stages": [ + "M0", + "M5", + "M9" + ], + "accRefs": [ + "ACC-Q-001..009", + "ACC-X-001..014" + ], + "status": "baseline-not-started", + "notes": "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" + } + ] +} diff --git a/config/research-baseline.json b/config/research-baseline.json new file mode 100644 index 0000000..68b2f16 --- /dev/null +++ b/config/research-baseline.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "readOnly": true, + "source": { + "rawSamples": "research/jpw7/demo", + "decodedSamples": "research/jpw7/decoded", + "syntaxInventory": "research/jpw7/syntax-inventory.json", + "optionsFontsInventory": "research/jpw7/options-fonts-inventory.json" + }, + "observedInventory": { + "samplePairCount": 33, + "majorSectionKinds": 8, + "braceTokenHeadKinds": 214, + "pageSectionSamples": 32, + "optionsSectionSamples": 33, + "fontsSectionSamples": 33, + "titleSectionSamples": 33, + "voiceSectionSamples": 33, + "wordsSectionSamples": 33, + "attachmentsSectionSamples": 33 + }, + "interpretation": "Inventory counts are research observations, not implementation or compatibility claims.", + "nextMilestone": "M1" +} diff --git a/config/resource-licenses.json b/config/resource-licenses.json new file mode 100644 index 0000000..5b1e69a --- /dev/null +++ b/config/resource-licenses.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "policy": "Only project-owned or explicitly redistributable resources may enter the Web build.", + "entries": [ + { + "id": "font.noto-sans", + "kind": "font", + "name": "Noto Sans", + "source": "https://github.com/notofonts/latin-greek-cyrillic", + "versionPolicy": "pin-exact-release-before-M3", + "license": "SIL Open Font License 1.1", + "distribution": "allowed-after-license-file-and-font-hash-are-recorded", + "status": "selected-not-bundled-at-M0" + }, + { + "id": "font.noto-sans-cjk-sc", + "kind": "font", + "name": "Noto Sans CJK SC", + "source": "https://github.com/notofonts/noto-cjk", + "versionPolicy": "pin-exact-release-before-M3", + "license": "SIL Open Font License 1.1", + "distribution": "allowed-after-license-file-and-font-hash-are-recorded", + "status": "selected-not-bundled-at-M0" + }, + { + "id": "audio.web-audio-oscillator", + "kind": "audio", + "name": "Web Audio API oscillator probe", + "source": "browser platform API", + "versionPolicy": "browser-version-recorded-per-acceptance-run", + "license": "platform API; no third-party asset", + "distribution": "no-binary-asset", + "status": "selected-for-M0-probe" + }, + { + "id": "research.jpw7-private-assets", + "kind": "research-reference", + "name": "Existing JPW7/private fonts and source media", + "source": "research/", + "versionPolicy": "content hashes are evidence-only", + "license": "not cleared for redistribution", + "distribution": "forbidden", + "status": "read-only-reference" + } + ] +} diff --git a/docs/JPW7_WEB排版实现参考.md b/docs/JPW7_WEB排版实现参考.md new file mode 100644 index 0000000..dc6823c --- /dev/null +++ b/docs/JPW7_WEB排版实现参考.md @@ -0,0 +1,438 @@ +# 用 Web 实现 JP-Word 类简谱排版功能 + +## 1. 推荐的总体架构 + +不要把乐谱做成 `contenteditable` 文本,也不要把所有内容直接画到 Canvas 后再反推编辑状态。推荐使用: + +```text +JPW-ABC / 编辑命令 + ↓ +Lexer + Parser + ↓ +语义模型 ScoreModel + ↓ +LayoutEngine + ↓ +LayoutSnapshot + ↓ +SVGRenderer + 交互层 +``` + +最小实现建议: + +- **TypeScript**:模型、解析器、排版器、命令系统; +- **SVG**:屏幕绘制、缩放、选择、打印和导出; +- **Canvas 2D**:只用于字体宽度测量和性能优化; +- **Web Worker**:文档较大时把解析和排版放到 Worker; +- **JSON**:Web 内部编辑格式;`.jpwabc` 作为导入/导出格式。 + +SVG 比 Canvas 更适合作为第一版:每个符号都有 DOM 节点,命中测试、选择、拖动、打印和局部重绘简单很多。 + +## 1.1 字体不是一个,而是多个字体角色 + +JPW7 的音符有“标准、端庄、典雅、手写”四种字形风格,并支持字号和宽高比调整;歌词、标题、普通文字、特殊文字又分别使用独立字体角色。因此 Web 版至少应拆分: + +```text +noteFont 音符字形 +lyricFont 歌词 +titleFont 标题 +textFont 普通文字 +specialFont 特殊符号/指法 +``` + +排版前必须等待 `document.fonts.ready`,用真实字体测量文字宽度。线条、弧线、房子等不要强行做成字体字符,应使用 SVG 几何图形。 + +## 1.2 固定纸张的方案选型 + +用户场景只考虑固定打印纸张宽度时,优先采用“固定页面 + 自定义 SVG 排版器”,不要把响应式网页排版当作核心。 + +### 推荐方案:自定义 SVG Engraving/Layout Engine + +```text +ScoreModel + → MeasureLayout + → LineLayout + → PageLayout + → 每页一个 SVG viewBox +``` + +为每种纸张定义固定配置: + +```ts +interface PaperProfile { + id: 'A4' | 'A5' | '16K' | 'custom'; + widthMm: number; + heightMm: number; + marginMm: { top: number; right: number; bottom: number; left: number }; +} +``` + +内部统一使用 `mm * 100` 或 `staffUnit`,例如 A4 页面使用 `29700 × 21000` 的 SVG `viewBox`。浏览器只负责缩放显示,排版本身不依赖 viewport 宽度。 + +### 可借鉴但不宜直接套用的库 + +- **abcjs**:可借鉴 ABC 的分小节、分行和 SVG 输出思路,但它面向五线谱 ABC,不直接支持 JPW 简谱。 +- **VexFlow**:可借鉴音符度量、formatter、SVG/Canvas 绘制,但其 glyph、拍号和五线谱模型与简谱差异很大。 +- **Verovio**:固定页面和 SVG 生成能力强,适合研究分页/排版思想;输入主要是 MEI/MusicXML,直接用于简谱需要重写符号层。 +- **OpenSheetMusicDisplay / AlphaTab**:更适合五线谱、MusicXML、吉他谱或 Tab,不建议作为 JPW7 简谱核心引擎。 +- **Paged.js 等 CSS 分页库**:适合外层页眉、页脚和文档分页,不适合负责音符内部的间距、歌词对位和弧线布局。 + +结论:这些库可以借鉴排版思想,但没有一个能直接替代 JPW7 的简谱布局器。最省事的做法是自建简谱对象和布局层,只复用 SVG、字体测量和 PDF 输出能力。 + +### 固定纸张下的分页 + +```css +@page { size: A4 portrait; margin: 0; } +.score-page { + width: 210mm; + height: 297mm; + break-after: page; + overflow: hidden; +} +``` + +实际谱面仍由 SVG 坐标控制;CSS 只负责把页面送到打印机。导出 PDF 时应嵌入字体,避免浏览器换字体后导致重新排版。 + +## 2. 不要直接使用字符串作为编辑模型 + +JP-Word 的核心是“基本符号 + 附属对象 + 页面对象 + 排版状态”。Web 版本也应保留这四层。 + +```ts +interface ScoreDocument { + meta: Meta; + voices: Voice[]; + lyrics: LyricVerse[]; + attachments: Attachment[]; + pageObjects: PageObject[]; + layout: LayoutOptions; +} + +interface Voice { + id: string; + symbols: BasicSymbol[]; +} + +interface BasicSymbol { + id: string; // 永久 ID,不要使用数组下标做锚点 + kind: 'note' | 'rest' | 'bar' | 'text' | 'control'; + pitch?: Pitch; + duration: Rational; // 例如 1/4、1/8,不要只保存下划线数量 + measureId: string; + attachments: string[]; // Attachment.id + manualGap?: number; + gapLocked?: boolean; +} + +interface Attachment { + id: string; + kind: string; + anchorId: string; // 锚定 BasicSymbol 或小节线 + offset: { x: number; y: number }; + size: { sx: number; sy: number }; + rotate: number; + color?: string; + occupancy: { top: boolean; right: boolean; bottom: boolean; left: boolean }; + payload: unknown; +} + +interface PageObject { + id: string; + x: number; y: number; + width: number; height: number; + payload: unknown; +} +``` + +**关键原则**:数组顺序表达音乐顺序,ID 表达关联关系,坐标属于排版结果,不是语义源数据。 + +## 3. 解析层:文本先变成语义对象 + +### 3.1 词法解析 + +先把输入拆成 token,不要在渲染器里用正则直接识别整行: + +```text +1 2 3 0 音级/休止符 +#1 b5 变音 +_ __ _. 时值修饰 +| |: :| 小节/反复 +[135] 和弦 +( ... ) 弧线、伴奏或分组 +{(3} ... 连音组 +{C:...} 局部间距/连接控制 +"文字" 标准文字 +``` + +### 3.2 语义归一化 + +把不同写法归一化成同一内部形式: + +```ts +{ + degree: 1, + accidental: 0, + octave: 0, + duration: { numerator: 1, denominator: 8 }, + tie: false, + sourceRange: [start, end] +} +``` + +调号、拍号、临时变音、八度标记最后再换算为显示音高和播放音高。不要让渲染器理解 `d1`、`1'`、`#1` 等原始字符串。 + +### 3.3 保留源位置 + +每个 token 保存 `sourceRange`,这样可以: + +- ABC 编辑器与图形编辑器双向定位; +- 解析错误时高亮原文; +- 未支持的控制码可以原样保留; +- 保存时尽量避免破坏用户原始格式。 + +## 4. 排版器的核心:先测量,再定位 + +排版器不要边解析边画。建议生成不可变的 `LayoutSnapshot`: + +```ts +interface LayoutSnapshot { + pages: PageLayout[]; + symbols: Record; + attachments: Record; +} + +interface SymbolLayout { + id: string; + page: number; + line: number; + x: number; + y: number; + width: number; + height: number; + anchorX: number; + anchorY: number; +} +``` + +### 4.1 测量阶段 + +对每个基本符号计算: + +```text +baseBox = 音符/休止符/文字的字体或路径边界 +lyricBox = 歌词字体实际宽度和高度 +attachmentBoxes = 弧线、房子、技法、特殊文字等边界 +``` + +浏览器中: + +- 先 `await document.fonts.ready`; +- 使用 `CanvasRenderingContext2D.measureText()` 测文字宽度; +- SVG 路径用固定几何尺寸或 `getBBox()`; +- 字体、字号、缩放统一换算到同一内部单位,例如 `1/100 mm` 或 `staffUnit`。 + +### 4.2 横向间距 + +每个基本符号有一个参考点。相邻参考点的间距可以用以下模型: + +```ts +preferredGap = Math.max( + minimumGap, + spacingTable[durationClass], + symbolBox.width + sidePadding, + lyricDemand, + attachmentDemand +); + +gap = symbol.gapLocked + ? symbol.manualGap! + : preferredGap; +``` + +目前通过实际 JPW7 的“设置水平定位(全局间距)”窗口和样例选项可以进一步确认:`spacingTable` 不是虚构的抽象,它对应可选的 `HorzSpacing_AW` 参数表;另有 `HorzSpacing_Gap` 参数表。程序提供“很紧、稍紧、标准、稍松、很松、线性、等距”七组方案,以及高级用户直接编辑参数表的入口。 + +因此 Web 实现应把间距设计成可替换表,而不是把公式写死: + +```ts +const spacingTable = { + '16th': [1.30, 1.97, 2.513, 2.987, 3.809, 4.527, 5.774, 6.861], + // 具体索引含义、缩放系数和 Gap 表仍需用受控样本校准 +}; +``` + +这些数字是样例中读取到的真实参数,不是 JPW7 的完整官方公式。 + +### 4.3 四方向占位 + +附属对象先相对锚点定位,再把占位需求反馈给排版器: + +```ts +function expandDemand(demand: Demand, box: Box, occupancy: Occupancy) { + if (occupancy.top) demand.top = Math.max(demand.top, -box.top); + if (occupancy.right) demand.right = Math.max(demand.right, box.right); + if (occupancy.bottom) demand.bottom = Math.max(demand.bottom, box.bottom); + if (occupancy.left) demand.left = Math.max(demand.left, -box.left); +} +``` + +例如: + +- 歌词通常占用下方空间; +- 弧线/房子可能占用上方; +- 连谱号可能占用左右空间; +- 特殊文字可以四方向分别开关占位; +- 页面文本框不进入这个计算。 + +### 4.4 折行、对齐和分页 + +折行只允许发生在小节边界: + +```text +for measure of measures: + if currentLine.width + measure.width > contentWidth: + finishLine(); + startLine(); + append(measure); +``` + +一行完成后: + +- 普通模式保留自然间距; +- 两端对齐模式把剩余宽度分配到基本符号间隙; +- 手动锁定的间距不被全局参数覆盖; +- 行高取本行上下占位的最大值; +- 当前页放不下时创建下一页。 + +## 5. 歌词、弧线和特殊文字 + +### 歌词 + +先建立歌词单位到基本符号的映射,再计算宽度: + +```ts +for (const lyricUnit of verse.units) { + const symbol = nextLyricBearingSymbol(cursor); + if (symbol) symbol.lyrics.push(lyricUnit); +} +``` + +`nextLyricBearingSymbol()` 应跳过休止符、弧线、伴奏和不可承载歌词的对象。歌词对位完成后,歌词宽度参与符号间距和行高计算。 + +### 弧线/房子 + +弧线不要保存成一堆已经算好的点,而保存: + +```ts +{ startAnchorId, endAnchorId, type, curve, direction, occupancy } +``` + +排版后根据两个锚点的最终坐标重新生成 SVG path。这样换行、拖动、改间距后弧线仍然正确。 + +### 特殊文字 + +特殊文字可以是: + +- 普通文本; +- ABC 小片段; +- 线条/图形; +- 可旋转、缩放、改色的对象。 + +建议把它们统一成 `Attachment`,由专门的 `AttachmentRenderer` 根据 `kind` 渲染;不要把所有特殊符号硬编码到主音符渲染器里。 + +## 6. SVG 渲染建议 + +推荐的 SVG 层次: + +```xml + + + + + 1 + 2 + + + + + +``` + +- `viewBox` 使用物理版心单位,CSS 缩放只改变显示比例; +- `data-symbol-id` 支持命中测试和选中; +- 选中框、拖动手柄、锚点线放在独立的 overlay layer; +- 打印时隐藏 overlay; +- 导出 PDF/PNG 时直接复用 SVG。 + +## 7. 编辑系统 + +使用命令而不是到处直接修改对象: + +```ts +execute({ type: 'InsertNote', afterId, note }); +execute({ type: 'MoveAttachment', id, dx, dy }); +execute({ type: 'SetManualGap', id, gap }); +execute({ type: 'UnlockGap', id }); +execute({ type: 'InsertLineBreak', measureId }); +``` + +每个命令: + +1. 修改 `ScoreModel`; +2. 标记受影响的小节/行; +3. 重新测量和排版; +4. 更新 SVG; +5. 写入 undo/redo 栈。 + +第一版可以先全量重新排版;文档较大后再按“受影响小节 → 受影响行 → 受影响页”增量更新。 + +## 8. 推荐开发顺序 + +### MVP-1:只做可见简谱 + +- 音符、休止符、小节线; +- 时值和调号; +- SVG 绘制; +- 按小节折行。 + +### MVP-2:做出 JP-Word 的排版特征 + +- 歌词自动对位; +- 弧线、连音、和弦; +- 四方向占位; +- 两端对齐; +- 手动间距锁定。 + +### MVP-3:做成可编辑软件 + +- 标准/扩展/页面/排版四种模式; +- 选择、拖动、缩放、旋转; +- 页面文本框; +- undo/redo; +- `.jpwabc` 导入/导出。 + +### MVP-4:输出和兼容 + +- SVG/PNG/PDF; +- 打印版式; +- MusicXML/MIDI; +- 完善全部特殊符号和播放参数。 + +## 9. 最容易走错的方向 + +- 不要用 `contenteditable + 空格` 实现谱面;换行和字体变化会破坏布局。 +- 不要把 `x/y` 作为唯一数据;坐标应由语义和排版状态派生。 +- 不要把歌词画成普通文本;必须有歌词对位和占位模型。 +- 不要把弧线保存为固定 SVG 路径;它应该通过起止锚点重算。 +- 不要一开始实现所有 JPW-ABC 控制码;先做基本符号、歌词、弧线、占位和锁定间距。 + +## 10. 最小结论 + +要参考 JP-Word 实现 Web 版,最值得复用的不是它的界面,而是这个思想: + +```text +语法 = 语义对象 +排版 = 约束求坐标 +编辑 = 修改对象或排版状态 +渲染 = 根据 LayoutSnapshot 绘制 +``` + +先做一个“保留模式”的 SVG 编辑器,再逐步扩展 JPW-ABC 兼容性,是成本最低、最不容易返工的路径。 diff --git a/docs/JPW7_实现原理与JPW-ABC语法调研.md b/docs/JPW7_实现原理与JPW-ABC语法调研.md new file mode 100644 index 0000000..d45d438 --- /dev/null +++ b/docs/JPW7_实现原理与JPW-ABC语法调研.md @@ -0,0 +1,803 @@ +# JP-Word:语法到简谱可视化编辑/渲染原理 + +> 本稿以安装包内帮助文档、帮助文档中的操作流程和 `.jpwabc` 样例为依据;重点不是逆向二进制,而是回答:**JP-Word 如何把语法/编辑操作变成可视化、可拖动、可排版、可打印的简谱**。 + +## 0. 证据等级 + +本文把结论分成三类: + +- **事实**:帮助文档或 `.jpwabc` 样例明确写出/展示的行为,例如四种音符字形、四方向占位、按小节换行、手工锁定间距、页面文本框不参与乐谱排版。 +- **实现推断**:根据这些可观察行为反推出的内部模型,例如“基本符号 + 附属对象 + 页面对象”的分层、先测量再排版的多阶段流程。这不是源码级证明。 +- **Web 实现建议**:TypeScript、SVG、`LayoutSnapshot`、命令系统等,是复刻方案,不是 JPW7 已确认使用的技术。 + +后文已尽量用“事实 / 推断 / 建议”区分;凡涉及内部类名、数据结构或具体数学公式,均不能当作 JPW7 源码事实。 + +## 1. 最重要的结论 + +JP-Word 不是“把一串数字按固定列打印出来”,而是一个带有**基本符号、附属符号、页面符号、排版状态**的编辑器。 + +可以抽象为: + +```text +编辑操作 / JPW-ABC 文本 + ↓ +基本符号序列 + 小节/拍值信息 + ↓ +歌词、弧线、技法、特殊文字等附属对象挂接到基本符号 + ↓ +测量字体和附属对象边界 + ↓ +按时值、歌词宽度、占位规则计算坐标 + ↓ +按小节折行、按页面分页、行末对齐 + ↓ +屏幕编辑 / 打印 / Word / PDF 等绘制输出 +``` + +因此它的核心不是单纯的“语法解析”,而是: + +1. **语法解析**:把音符串变成有语义的基本符号。 +2. **对象关联**:把歌词、弧线、房子、表情、指法等绑定到某个音符或小节线。 +3. **排版约束**:根据时值和对象边界计算空间,而不是依赖用户输入空格。 +4. **交互编辑**:用户拖动、加宽、换行、锁定后,改变的是对象/排版属性,而不是简单改字符串。 + +## 2. 文件和编辑模型 + +`.jpwabc` 是 JPW-ABC 保存格式。样例是 Unicode 文本,包含这些主要节: + +```text +.Options 文档排版/显示参数 +.Fonts 音符、歌词、标题、特殊文字等字体角色 +.Title 标题、作者、调号、拍号、速度、表情等 +.Voice 主旋律/基本符号流 +.Words 歌词段落及歌词对位信息 +.Attachments 挂接在音符/小节线上的附属对象 +.Page 页面级对象,例如文本框;可缺省 +``` + +帮助文档把对象分成三类: + +### 2.1 基本符号 + +基本符号是谱面主序列中的对象,例如: + +- 音符、休止符; +- 小节线、拍号、调号; +- 标准文字; +- 一些直接参与横向排版的符号。 + +光标、选择范围、插入/删除、音符间距,主要都以基本符号序列为操作对象。 + +### 2.2 附属符号(Attachments) + +附属符号挂在某个基本符号或小节线上,例如: + +- 歌词; +- 标准弧线、特殊弧线、房子; +- 强弱、速度、表情、演奏技法; +- 指法、和弦名、装饰音; +- 特殊文字和扩展符号。 + +它们有自己的相对位置、尺寸、颜色、旋转角度和“是否占位”属性,但通常跟随锚定的基本符号移动。 + +### 2.3 页面符号 + +文本框属于页面符号:它使用页面左上角作为坐标原点,不跟随音符移动,也不参与乐谱排版占位。适合页眉、页脚、页码和大段说明文字。 + +## 3. 字体在 JPW7 中的作用 + +结论是:**JPW7 主要依赖字体/字形来显示数字和文字,但不是“所有谱面都由一个字体完成”。** + +### 3.1 音符字形 + +帮助文档的“设置音符字体”对话框明确提供四种音符字形风格: + +```text +标准、端庄、典雅、手写 +``` + +每种音符字形还可以设置: + +- 字体大小; +- 宽高比/横向压缩; +- 全局音符字体; +- 临时声部的局部字体和缩放。 + +因此 `1、2、3` 等基本数字很可能是由专用音符字形绘制的,而不是浏览器默认数字字体。帮助文档还特别说明,音符字体的改变会影响重新排版。 + +但减时线、弧线、房子、连谱号等不能简单理解为字体字符:它们具有长度、端点、曲率、方向和占位属性,应作为几何对象或特殊对象绘制。 + +### 3.2 文本类字体 + +`.Fonts` 样例中出现的角色和字体包括: + +```text +Title 标题 +SubTitle 副标题 +WordsByAndMusicBy 作者/作曲者 +KeyAndMeters 调号/拍号 +Lyric 歌词 +LyricPrefix 歌词前缀 +LyricVerseIndex 歌词段号 +Text 标准文字 +Expression 表情/演奏说明 +Intro 引语 +Memo1~Memo4 页面文本框 +PageNumber 页码 +``` + +样例实际使用过:`Arial`、`微软雅黑`、`宋体`、`仿宋`、`楷体`、`幼圆`、`黑体`、`方正小标宋_GBK`、`迷你简卡通` 等系统字体。也就是说标题、歌词和说明文字走普通 Windows 字体选择,而不是和音符共用一个字体。 + +### 3.3 专用/扩展字体 + +样例还出现: + +```text +fuCustomDigit = JpwCore, 6.000 +TaoDi = JPW_TaoDi, 6.000 +``` + +其中 `JPW_TaoDi` 用于陶笛指法/特殊符号,附件通过 Unicode 码点选择字形,例如 `\\x004F`、`\\x0046`。这说明特殊文字可以采用“字体索引 + 字符码点”的方式扩展符号库。 + +### 3.4 对 Web 实现的含义 + +Web 版不要只加载一个 `jpw.woff2`: + +```text +音符字形字体/字形集 +歌词字体 +标题字体 +普通文字字体 +特殊符号字体 +``` + +应把字体作为可替换资源,并在排版前等待 `document.fonts.ready`,使用实际字体测量歌词、标题和特殊文字宽度。若要复刻 JPW7 的音符外观,需要取得合法的音符字形资源或自行绘制 SVG 字形;不能假定 Arial/微软雅黑 的数字外观等价。 + +## 4. JPW-ABC 语法的可视化含义 + +以下是样例和帮助文档反复出现的语法类别。精确的完整词法仍应以软件版本为准,但这些类别已经足够解释渲染过程。 + +### 3.1 音高 + +```text +1 2 3 4 5 6 7 简谱音级 +0 休止符 +#1、b5、#b2 升降号 +d1、dd1、g1 八度变化 +5,、7,,、1' 低音/高音标记 +``` + +样例同时使用 `d/g` 和逗号/撇号两套八度表达;实际渲染时它们都会归一化为内部的音高/音区属性。`X` 是无固定音高的节奏/锣鼓字符,例如 `X动_`、`X台__`。 + +数字不是最终的绝对音高,而是结合调号、八度、临时变音记号和声部设置后,得到显示音名、播放音高和 MusicXML 音高。 + +### 3.2 时值 + +```text +_ 减时线 +__ 更短时值 +. 附点 +_. 减时线 + 附点 +``` + +时值不仅影响播放,也直接影响自动排版的水平间距:帮助文档明确说明,时值越长,默认音符间距越宽。 + +### 3.3 结构符号 + +```text +| 小节线 +||、|] 行/声部结束类标记 +|: 反复开始 +:| 反复结束 +[135] 和弦/同时发音组合 +[5,2572'] 带低音/高音标记的组合 +( ... ) 弧线、伴奏或成组范围 +{(3}... 三连音等多连音分组 +{3}2、{3b56}5- 倚音/装饰音 +``` + +弧线、房子、连音等不是普通字符,而是有起点、终点、方向和占位规则的对象。括号在不同上下文中还可表示伴奏/临时声部范围,不应简单等同于文本括号。 + +### 3.4 控制码与嵌入式 ABC + +JPW-ABC 中还存在控制码,用于调号、拍号、间距、技法或特殊符号。例如样例中出现: + +```text +{1=F,2/4} 调号 + 拍号 +{J=80} 速度 +{C:1.970} 当前基本符号的显式间距 +{C:0,All,Unconnect} 减时线/连接控制 +{DunYin}、{BoYin} 技法/装饰标记 +{ZhongYin} 中音/声部样式标记 +``` + +`{C:...}` 后面还可以带 `None`、`Other`、`UnderlineOnly` 或一组括号数值;这说明它不只是“插入空格”,而是保存了某个基本符号的局部排版/连接状态。 + +帮助文档还说明: + +- 标准文字可以嵌入简单音符、调号和拍号; +- 特殊文字可以嵌入 ABC 代码,生成复杂音乐符号甚至图形; +- 如果标准文字同时使用换行符 `\n`,其中的 ABC 代码会失效; +- 线条类特殊文字可以直接通过 ABC 代码调整长度。 + +这说明 ABC 不是只用于“主旋律”,而是一个贯穿标题、标准文字、特殊文字和附属符号的局部绘制语法。 + +### 3.5 声部行尾元数据 + +样例中的声部行常以类似下面的记录结束: + +```text +... | $(True,0.00,0.20) +... | $(False,0.00,1.00,False,False,True) +``` + +这类 `$(...)` 不是音符本身,而是声部/行的显示、比例、位置或播放相关属性。解析器应把它作为“行元数据”处理,不能把它当成普通音符 token。 + +声部中还可以混入引号文字: + +```text +"此处省略2小节间奏" +``` + +它是标准文字基本符号,参与横向排版;而特殊文字/扩展符号则作为可自由定位的附属对象保存。 + +## 4. 歌词如何变成可视化对象 + +歌词不是直接拼在音符字符串后面,而是一个独立的歌词段对象。 + +典型保存形态类似: + +```text +W1@1,9: +歌词文本... +``` + +其中可以表达: + +- 第几段歌词; +- 从哪个声部、哪个基本符号开始; +- 自动歌词对位或手工对位; +- 歌词的水平/垂直调整; +- 多段歌词合并或拆分。 + +帮助文档描述的自动对位逻辑是: + +1. 用户从某个音符开始输入歌词; +2. 软件把歌词单位依次分配给后续可承载歌词的音符; +3. 跳过休止符、弧线、伴奏等不应承载歌词的对象; +4. 以歌词字体实际宽度参与横向排版,避免歌词相互重叠; +5. 用户仍可在扩展模式下拖动歌词进行人工修正。 + +所以歌词的渲染不是 `x += 固定宽度`,而是“歌词测量宽度 + 对位规则 + 音符间距约束”。 + +## 5. 从语法到坐标:自动排版算法 + +帮助文档给出了非常明确的排版模型。 + +### 5.1 每个基本符号有参考点 + +“音符间距”是相邻基本符号参考点之间的距离。音符、小节线、拍号、标准文字都属于这个横向序列。 + +自动模式下,间距主要由以下因素共同决定: + +```text +基础时值间距 ++ 音符/符号自身宽度 ++ 歌词的水平需求 ++ 附属对象的占位边界 ++ 小节线两侧的间隙 ++ 全局水平定位参数 +``` + +### 5.2 附属对象先测量,再影响基本符号 + +帮助文档明确说,软件排版时要先计算每个基本符号关联的所有附属符号边界矩形,才能决定下一个基本符号的位置以及下一行的垂直位置。 + +因此可以抽象成: + +```text +for basic in basic_symbols: + box = measure(basic) + for attachment in basic.attachments: + a_box = measure(attachment) + if attachment.occupies_top: box.top = max(box.top, a_box.top) + if attachment.occupies_bottom: box.bottom = max(box.bottom, a_box.bottom) + if attachment.occupies_left: box.left = max(box.left, a_box.left) + if attachment.occupies_right: box.right = max(box.right, a_box.right) + place_next_basic_symbol(box) +``` + +这就是“占位”机制:附属符号可以分别决定是否在上、下、左、右方向占用排版空间。 + +帮助文档列出的规则: + +- 标准房子、标准弧线、标准技法:四方向占位; +- 歌词:上下总是占位,水平占位使用特殊算法; +- 特殊弧线、特殊房子、渐强渐弱、临时声部:可设置垂直占位; +- 连谱号:可设置水平占位; +- 特殊文字、扩展符号:四个方向分别可设置占位。 + +### 5.3 自动折行和分页 + +自动排版的边界是页面版心,不是无限画布: + +1. 先按基本符号和附属对象计算一行的宽度; +2. 以小节为单位折行,而不是随便从一个字符中间断开; +3. 一行超出右边界时,把行末小节移到下一行; +4. 页面垂直空间不足时自动新增页面; +5. 附属对象的上下占位会影响行距; +6. 段落属性可以控制分页、分节、缩进和局部行距。 + +### 5.4 两端对齐 + +“两端对齐”不是拉伸页面图片,而是重新增加一行内基本符号之间的横向间距,使行末对齐右边界。 + +因此它作用在: + +```text +x[i+1] = x[i] + automatic_gap[i] + justification_extra_gap +``` + +而不是改变音符本身大小。 + +### 5.5 水平间距公式的当前调研结果 + +目前可以确认它不是一个简单的 `固定字符宽度 × 字符数` 公式。JPW7 实际打开的“设置水平定位(全局间距)”窗口包含: + +- 从“很紧”到“等距”的全局预设; +- 增大/减小间距操作; +- “使用排版参数表格(高级用户)”; +- “根据参数设定表格”; +- `HorzSpacing_AW` 与 `HorzSpacing_Gap` 两类参数。 + +这说明自动水平排版至少是**按时值分类的间距表 + 最小间隔/宽度约束 + 对齐调整**,而不是一个单一线性公式。帮助文档明确给出行为规则,但没有公布表格每一列的正式定义和最终组合公式。 + +当前最接近事实的模型是: + +```text +baseGap = spacingTable[durationClass] +widthDemand = max(glyphWidth, lyricWidth, occupiedAttachmentWidth) +autoGap = combine(baseGap, widthDemand, gapOptions) +if (lineJustified) autoGap += distributedSlack +if (symbolGapLocked) gap = manualGap +else gap = autoGap +``` + +其中 `spacingTable`、`combine()` 和各字段的精确系数仍需通过逐项改变一个参数、读取符号坐标/换行结果的受控实验恢复;上式是待验证模型,不是 JPW7 官方公式。 + +### 5.6 受控实验进度 + +已在实际 JPW7 窗口中打开“设置水平定位(全局间距)”: + +- 预设包括“很紧、稍紧、标准、稍松、很松、线性、等距”; +- `Ctrl+Alt+1` 到 `Ctrl+Alt+7` 对应这 7 组全局方案; +- `Alt+[ / Alt+]` 是全局间距微调; +- 另有“符号间距”微调,用于相邻符号两侧的局部间距; +- 高级面板提供“使用排版参数表格”和“根据参数设定表格”。 + +样例 `.Options` 中实际出现: + +```text +HorzSpacing_AW = 16th, 1.300, 0.600, False, + 1.300,1.970,2.513,2.987,3.809,4.527,5.774,6.861 +HorzSpacing_Gap = 0.000, 1.000, 0.700, 1.000, 0.500, + True, False, False +``` + +因此目前能确定的结构是: + +```text +时值类别 → AW 参数表 → 字符/歌词/附属对象宽度约束 + → Gap 参数表 → 行宽/两端对齐/锁定状态 +``` + +`等距`和`线性`不是普通字体效果,而是全局间距表的不同生成方案;高级表格则允许用户直接改写这些时值类别的间距。下一步实验目标是:逐个设置 `AW` 表中的一个值,选中同一基本符号,读取状态栏的 `Pos/Bounds`,以此恢复表项与坐标变化的对应关系。 + +另外,二进制中保留的布局阶段/调试字符串给出了一个可检验的候选顺序;它不是已捕获的运行时调用序列,因此不把它当作源码级事实: + +```text +LocateAllSymbols + GetAllSymbolBoundsInfo + AutoSetAWCoef + Lyric_CheckAllOcupy + GetParagraphRangeArray + GetParagraphInfoArray + GetLineRangeArray + LineInfoArray_GetBasicIndexRange + AdjustAWCoefToFitLayoutWidth + Basic_UpdateSymbols + UpdateAllBasicSymbolsHorzPosFromAW + CreateArcs + GetFangZiInfoArray + CreateFangZis + SingleLineToSinglePage + GetPageRangeArray + SinglePageToMultiPage + ProcessAutoAlign_AttachBranches + UpdateAllAttachSymbolsPos + ProcessAutoAlign_WrappedAttachArcs + ProcessAutoAlign_WrappedAttachFangZis +``` + +如果这些标记对应实际排版路径,则候选流程是:先收集边界和歌词占位,再自动设置/调整 `AW` 系数,随后更新基本符号水平坐标,最后生成弧线、房子并自动调整附件位置。它仍然没有给出 `AutoSetAWCoef` 的数值公式;本轮没有可靠捕获每一步的运行时事件,所以只能把它作为实现推断。原始字符串证据见 `research/jpw7/layout-stage-evidence.txt`。 + +## 6. 手动编辑为什么能保持可编辑 + +JP-Word 把自动排版和手动排版分开: + +- 自动排版:使用全局水平定位、垂直定位和对象占位规则; +- 手动排版:直接调整某个符号间距、某行行距、小节宽度、行宽、缩进或换行点; +- 手动调整后,该间距被“锁定”,右上角显示浅蓝色星号; +- 全局间距修改不会影响已锁定的符号; +- 使用“解锁”后,符号重新回到自动排版规则。 + +这说明文档中至少保存了两种信息: + +```text +语义内容:音符、时值、歌词、附属对象 +排版状态:自动/锁定、手工间距、回车、行距、缩进、分页 +``` + +也正因为排版状态独立保存,用户可以先输入乐谱,再反复调整版式,而不会破坏音符语义。 + +## 7. 四种编辑模式对应的内部职责 + +### 标准模式 + +面向基本符号序列: + +- 插入/删除音符; +- 修改时值; +- 插入小节线; +- 添加标准弧线、和弦、歌词和标准文字; +- 通过键盘快捷键编辑主旋律。 + +### 扩展模式 + +面向附属对象: + +- 点击选中附属符号; +- 拖动位置; +- 调整大小、比例、旋转; +- 改变线条端点; +- 复制粘贴; +- 设置四方向占位。 + +### 页面模式 + +面向页面坐标: + +- 添加文本框; +- 设置页眉、页脚、页码和大段文字; +- 文本框可以跨页拖动或复制; +- 文本框不参与乐谱自动排版。 + +### 排版模式 + +面向全局/局部排版状态: + +- 调整全局水平间距; +- 调整全局行距; +- 修改单个符号间距; +- 移入/移出小节; +- 强制换行; +- 对齐小节线; +- 调整缩进、行宽、段落属性; +- 锁定或解锁自动间距。 + +## 8. 一个最小实现应如何设计 + +如果要自己实现一个类似 JP-Word 的简谱编辑器,最小可行架构不是“解析后直接绘图”,而是四层: + +```text +Document +├── Sections / metadata +├── BasicSymbol[] +│ ├── pitch / rest / duration / measure / voice +│ ├── manualGap / locked +│ └── Attachment[] +├── LyricVerse[] +└── PageObject[] +``` + +渲染过程: + +```text +parse(text) + → build_basic_symbols() + → split_measures() + → align_lyrics() + → attach_objects() + → measure_fonts_and_boxes() + → layout_lines_by_measure() + → apply_manual_locks_and_justification() + → paginate() + → draw_basic_symbols() + → draw_attachments() + → draw_page_objects() +``` + +编辑操作只修改模型: + +```text +插入音符 修改 BasicSymbol[] +拖动特殊文字 修改 Attachment.offset +调整间距 修改 BasicSymbol.manualGap + locked +按回车换行 修改 line-break / paragraph 属性 +添加歌词 新建 LyricVerse 并建立 anchor 映射 +``` + +然后触发局部或全局重新排版、重绘。这是它能同时做到“所见即所得”和“保存后仍然可编辑”的关键。 + +## 9. 帮助文档依据 + +本次修正后的分析主要依据: + +- `research/jpw7/help-txt/JPW图文教程-快速入门.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样打歌谱.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样使用临时声部.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样排版.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样谱字混排.txt` +- `research/jpw7/help-txt/附录:常见乐谱元素概览.txt` +- `research/jpw7/decoded/*.txt` 中的 `.jpwabc` 样例 + +## 10. 为什么它像 Word:实现模型推断 + +**以下不是源码事实,而是基于帮助文档行为的实现推断。** 从帮助文档描述的“段落、行距、缩进、分页、文本框、手工锁定间距、打印版式”可以推断,JPW7 的内部模型很可能接近下面的结构: + +```text +Document +├── Page / Section +│ ├── Paragraph / ScoreLine +│ │ ├── Voice / Measure +│ │ │ └── BasicSymbol[] +│ │ └── AnchoredAttachment[] +│ └── PageObject[] +└── GlobalStyles / Fonts / LayoutOptions +``` + +这里的“乐谱行”类似 Word 的段落,但内容不是普通字符,而是音符、小节线、歌词锚点和可定位对象。 + +### 10.1 可能的排版过程 + +```text +编辑命令 + → 修改语义对象 + → 将声部切成小节 + → 测量每个基本符号和字体 + → 计算歌词/附件占位 + → 生成一行 + → 超出版心则回退整个小节 + → 计算本行高度 + → 超出页面则分页 + → 绘制页面和对象 +``` + +这与 Word 的“字符测量 → 行布局 → 段落布局 → 分页 → 绘制”非常相似,只是 JPW7 把“字符”替换成了“音乐基本符号”,并增加了小节边界和音乐对象锚点。 + +### 10.2 为什么手工调整不会破坏音乐内容 + +它把两类数据分开保存: + +```text +语义层:音高、时值、声部、小节、歌词、弧线 +版式层:间距、换行、缩进、行距、分页、锁定状态 +``` + +用户拖动音符或调整间距时,通常改变的是版式层;因此播放、歌词对位和导出仍然可以使用同一份语义层数据。 + +### 10.3 屏幕和打印共享布局结果(较强推断) + +它的“所见即所得”体验使“屏幕显示和打印至少共享同一套排版约束”成为较强推断,但帮助文档没有证明二者一定共享同一个缓存对象或同一份坐标快照: + +```text +LayoutSnapshot + ├── page rectangles + ├── line rectangles + ├── symbol positions + └── attachment paths/boxes +``` + +屏幕只是把快照绘制到窗口,打印/PDF 则把同一个快照绘制到打印设备或页面设备。这样才能保证屏幕和打印版式一致。 + +### 10.4 帮助文档明确给出的排版步骤 + +这部分不是猜测,帮助文档《怎样排版》直接给出了行为规则: + +1. **基本间距**:相邻基本符号通过“参考点”定义间距;自动模式下,水平定位参数控制间距,主要由音符时值决定,时值越大间距越宽。 +2. **附件边界**:先计算每个基本符号关联的附属符号边界矩形,再决定下一个基本符号的位置和下一行的垂直位置。 +3. **歌词约束**:歌词自动对齐音符;歌词大小和位置会撑开音符间距,避免歌词重叠。歌词上下方向始终占位,水平方向使用专门算法。 +4. **方向占位**:标准房子/弧线/技法四向占位;特殊弧线、房子、渐强渐弱、临时声部可控制垂直占位;连谱号控制水平占位;特殊文字/扩展符号四向独立控制。 +5. **折行**:以小节为单位自动折行;一行超宽时,把行末小节整体移动到下一行。 +6. **行距**:垂直定位参数控制全局行距;段落属性可以单独控制行间距。 +7. **分页**:页面放满后新增页面。 +8. **两端对齐**:增大行内基本符号之间的间距,使各行末端对齐版心右边界。 +9. **手工覆盖**:手动调整的基本符号间距会锁定,不再受全局水平定位影响;解锁后恢复自动排版。 +10. **段落控制**:一个段落由若干行组成,一行由若干小节组成;回车可以强制换行、产生空行、控制段落缩进/分页/分节。 + +可直接实现为: + +```text +for paragraph: + for line: + for measure: + measure_width = layout_measure(measure) + if line.width + measure_width > content_width: + move_measure_to_next_line() + else: + append_measure() + + line.height = max(base_height, attachment_top, attachment_bottom) + + if paragraph.justify: + distribute_remaining_width_to_basic_symbol_gaps() + +paginate_when_page_height_is_full() +``` + +上面伪代码的步骤来自文档;其中 `layout_measure()` 内部的具体参数公式仍未公开。 + +### 10.5 对 Web 复刻的直接结论 + +Web 版应参考 Word 的**保留模式排版器**,而不是普通富文本编辑器: + +- 页面和段落负责分页、行距、版心; +- `BasicSymbol[]` 负责音乐语义; +- `Attachment[]` 负责歌词、弧线和特殊对象; +- `LayoutSnapshot` 负责可视化坐标; +- SVG 负责显示和命中测试; +- 编辑命令只修改模型,修改后重新排版。 + +## 11. 实机黑盒验证 + +以下结果不是文档推断,而是直接在 Windows 上启动 `Jpw7free_730d.exe`,通过命令行参数打开测试文件后,从 JPW7 窗口状态栏和 UI 自动化树读取的结果。 + +### 11.1 删除 `.Attachments` + +以《康定情歌》为基准制作空 `.Attachments` 变体: + +```text +原文件:16 小节,1 页,6 行,230 个符号,ABC L=9422 +无附属对象:16 小节,1 页,6 行,163 个符号,ABC L=2578 +``` + +小节数、页数、行数保持不变,但符号数显著减少,说明 `.Attachments` 会被解析成可计数的编辑对象,而不是绘制完成后才存在的像素。 + +### 11.2 删除 `.Words` + +同一首《康定情歌》制作空 `.Words` 变体: + +```text +无歌词:16 小节,1 页,6 行,152 个符号,ABC L=9116 +``` + +歌词节被删除后,符号数再次减少,说明歌词也进入了 JPW7 的编辑/排版对象集合。 + +### 11.3 大型样例 + +《JP-Word介绍-功能简介》实际打开结果: + +```text +原文件:40 小节,2 页,49 行,272 个符号,ABC L=2209 +无附属对象:40 小节,2 页,49 行,271 个符号,ABC L=2134 +``` + +这个样例的附属对象数量较少,因此删除它没有触发行数变化;这不能证明附属对象不参与布局,只说明该样例的版心余量足够。 + +### 11.4 UI 直接暴露的运行数据 + +加载样例时,状态栏实际显示: + +```text +第1小节, 第1页, 第1行, 第1列 (1,2/4,2.00) +Page=43.6,37.6mm, View=64.8,42.6mm, Device=245,161 +ABC: 1行,1列,Sel=0,0,L=... +``` + +选中附属对象时还会显示类似: + +```text +Symbol: 渐强渐弱, Index=103, +Pos=103.2,123.8mm, +Bounds=-3.2,-4.6,32.8,10.2 +``` + +这直接证明运行时至少存在:对象索引、页面坐标、视图坐标、设备坐标和对象边界信息。它支持“对象模型 + 坐标排版”的结论,但仍不能证明内部类名或具体布局公式。 + +## 12. 运行时 API 验证 + +这部分通过 Frida 在 Windows 交互会话中 spawn JPW7 并挂钩实际 API 得到,不是帮助文档推断。 + +实际观察到: + +```text +CreateFontIndirectW MS Shell Dlg、宋体、Tahoma、Courier New、Segoe UI... +GdipCreateFontFamily... Arial、微软雅黑、仿宋、楷体 +GdipPrivateAddMemoryFont 222636 字节私有内存字体 +GdipMeasureString 约 487 次 +GdipAddPathString 约 1397 次 +GdipDrawPath 约 1375 次 +GdipFillPath 约 1402 次 +ExtTextOutW 实际绘制窗口/菜单文本 +``` + +其中 `GdipMeasureString` 实际测量了数字和大量 `U+F000` 附近的私有区字符,`GdipAddPathString` 将字符串转成 GDI+ Path,再由 `GdipDrawPath`/`GdipFillPath` 绘制。 + +因此现在可以把以下结论提升为**运行时事实**: + +1. JPW7 确实使用 GDI/GDI+,不是纯粹的普通文本控件绘图; +2. 它确实做字体测量; +3. 谱面/特殊符号渲染路径包含“字符串/字形 → GDI+ Path → 路径绘制/填充”; +4. 它加载了一套私有内存字体; +5. 普通窗口文字至少有一部分通过 GDI `ExtTextOutW` 绘制。 + +私有字体资源身份已通过 PE `RCDATA/CORETTF`、`RCDATA/TAODI` 提取并确认内部名为 `JpwCore`、`JPW_TaoDi`;新增图谱显示样例实际使用 35 个私有区 codepoint,并记录了 `fsType`/版权元数据,但尚未把 `GdipAddPathString` 的每次调用稳定映射到音符/附件对象,也未完成私有区字形的官方语义和 Web 授权确认。追踪摘要见 `research/jpw7/runtime-trace-gdi-summary.md`,字体报告见 `research/jpw7/private-font-verification.md` 和 `research/jpw7/private-glyph-semantics.md`。 + +## 12.1 证据文件与复核材料 + +- 缺口/证据矩阵:`research/jpw7/evidence-matrix.md` +- AW/Gap 公式实验:`research/jpw7/formula-experiment.md` +- 全局预设实际实验:`research/jpw7/preset-experiment.md` +- 页面宽度实验记录:`research/jpw7/logs/page-width/` +- 布局阶段证据:`research/jpw7/layout-stage-evidence.txt` +- GDI/GDI+ 运行时追踪摘要:`research/jpw7/runtime-trace-gdi-summary.md` +- 原始运行时 JSONL 与统计:`research/jpw7/logs/` +- 打印/EMF/PDF 实验:`research/jpw7/print-export-experiment.md` +- 黑盒输入/输出表:`research/jpw7/blackbox-results.tsv` +- Windows UI 失败记录:`research/jpw7/ui-blocker-log.md` +- 复刻步骤:`research/jpw7/repro/README.md` +- 完成审计:`research/jpw7/final-audit.md` +- 样例/测试副本哈希:`research/jpw7/hashes.sha256` +- 解码样例:`research/jpw7/decoded/*.txt` +- 帮助文本:`research/jpw7/help-txt/*.txt` +- 原始 EXE SHA-256:`8d19c545c9a2ce5c30c2a219c400dcf402d2450bdae545eed4532e28625bb25e` + +## 12.2 样例语法、对象和持久化库存补充 + +本轮对 `research/jpw7/decoded/*.txt` 的 33 个解码样例做了机械库存,结果用于缩小实现前的未知范围,不把库存结果误写成完整官方语法: + +- 主要区块覆盖 `.Options`、`.Fonts`、`.Title`、`.Voice`、`.Words`、`.Attachments`、`.Page`,并观察到 214 个花括号 token 头;样例中出现私有区字符,说明不能只按 ASCII 数字文本设计词法层。 +- `.Attachments` 至少出现 `Text`、`Extend`、`Often`、`Brace`、`Branch`、`Arc`、`FangZi`、`Dynamic`;锚点库存含 `m,n`、`mR`、双 `@` 和带括号偏移的形态。 +- `.Words` 出现 `W1/W2/W3`、`W1-2`、`W1(True)` 等行头;歌词需要独立的行/段/锚点模型。 +- `.Options` 观察到 10 类记录键,覆盖 `Paper`、`HorzSpacing_AW`、`HorzSpacing_Gap`、`VertSpacing`、`Digit`、`Lyric`、`DrumChar`、`Accomp`、`Arc`、`FitFactor`;`.Fonts` 观察到 31 类字体角色记录。 +- `.Voice`/附件文本中出现 `cpo:PV`(音色/音量/效果)、`cpo:PR`(节奏动作)、`cpo:PO`(播放顺序)和 `PlayModeAuto/PlayModeAsDiff`;这证明播放语义在源文件层有独立控制记录,不应从 SVG 位置反推。 +- Windows 安装目录没有独立字体文件、注册表没有 JPW/TaoDi 字体项;但已从原始 EXE 的 `RCDATA/CORETTF`、`RCDATA/TAODI` 提取并确认 `JpwCore`、`JPW_TaoDi`,资源以私有内存字体方式加载;字形到语义对象的全量映射仍未恢复。 + +库存原始结果和可重复脚本: + +- `research/jpw7/syntax-inventory.md` / `.json` +- `research/jpw7/object-association-inventory.md` / `.json` +- `research/jpw7/options-fonts-inventory.md` / `.json` +- `research/jpw7/options-persistence-findings.md` +- `research/jpw7/options-writeback-verification.md` +- `research/jpw7/program-options-ui-inventory.md` +- `research/jpw7/playback-backend-verification.md` +- `research/jpw7/midi-export-verification.md` +- `research/jpw7/midi-event-inventory.md` / `.json` +- `research/jpw7/midi-source-prefix-alignment.md` / `.json` +- `research/jpw7/midi-source-to-midi-alignment.md` / `.json` +- `research/jpw7/private-glyph-semantics.md` +- `research/jpw7/runtime-pathtext-analysis.md` +- `research/jpw7/object-relocation-diff.md` +- `research/jpw7/object-relocation-cross-boundary.md` +- `research/jpw7/remaining-gap-closure.md` +- `research/jpw7/repro/make_cross_boundary_variants.py` +- `research/jpw7/scripts/inventory_jpwabc.py` +- `research/jpw7/scripts/inventory_objects.py` +- `research/jpw7/scripts/inventory_options_fonts.py` +- `research/jpw7/technical-gap-register.md` +- `research/jpw7/control-code-observations.md` +- `research/jpw7/binary-feature-index.md` + +## 13. 结论边界 + +帮助文档能确定上述**用户可观察的数据关系、编辑行为和排版约束**,但不能证明具体的内部类、缓存结构、布局公式或绘图库。因此: + +- “有基本符号、附属对象、页面对象这类逻辑层次”:**高可信实现推断**; +- “内部一定叫 `Document/Page/Paragraph/BasicSymbol`”:**未知**;这些只是便于 Web 复刻的命名; +- “一定使用同一份 `LayoutSnapshot` 做屏幕和打印”:**较强推断,未被文档直接证明**; +- TypeScript、SVG、Canvas、Worker:**Web 实现建议,不是 JPW7 事实**。 + +文中的 EBNF 和伪代码是复刻参考,不是 JP-Word 官方源码或正式标准。 + +本结论以帮助文档和 `.jpwabc` 样例为主,重点是可复现的排版行为,而不是二进制逆向。 diff --git a/docs/JPW7在线简谱编辑器-产品需求文档.md b/docs/JPW7在线简谱编辑器-产品需求文档.md new file mode 100644 index 0000000..a5ca4f6 --- /dev/null +++ b/docs/JPW7在线简谱编辑器-产品需求文档.md @@ -0,0 +1,244 @@ +# JPW7 在线简谱编辑器产品需求文档 + +> 版本:v1.1 +> +> 文档状态:产品基线,供设计、开发、测试和评审使用;不表示当前已有可运行前端。 +> +> 证据规则:`事实` 来自 JPW7 v7.30 帮助文档、`.jpwabc` 样例或 Windows 黑盒实测;`推断` 是根据行为建立的对象模型;`Web 建议` 是本产品的实现要求,不代表 JPW7 内部源码。 + +## 1. 产品定义 + +### 1.1 产品名称 + +JPW7 在线简谱编辑器。 + +### 1.2 产品定位 + +一个以固定打印纸张为画布的 Web 简谱编辑器,面向歌谱、合唱谱、合奏谱、器乐谱和教学练习场景,提供: + +- `.jpwabc` 导入、编辑和限定支持集的兼容导出; +- 独立 `.jianpu.json` 工程文件,完整保存音乐内容、未知原文和 Web 手动排版; +- 与 JPW7 行为相近的固定页面排版; +- SVG 矢量预览、打印和 PDF 输出; +- 浏览器内在线播放,支持节拍、时值、声部和常见反复; +- 在兼容范围内保留 JPW-ABC 中暂未实现的内容,避免静默丢失。 + +“1:1”指用户可观察的页面结构、编辑结果、分页和播放行为尽量与参考软件一致,不要求复制 GDI/GDI+、Delphi 控件或 JPW7 的内部类名。 + +### 1.3 目标用户 + +| 用户 | 主要任务 | +|---|---| +| 歌谱制作者 | 录入旋律、歌词、标题、速度和反复记号并打印 | +| 合唱/合奏编配者 | 管理多声部、声部名称、节拍对齐和简易播放 | +| 器乐/戏曲制作者 | 添加指法、特殊文字、锣鼓经、散板和过门 | +| 音乐教师/学生 | 在线试听、从光标播放、跟谱练习、打印讲义 | +| 文档迁移用户 | 打开既有 `.jpwabc`,修订后再导出 | + +## 2. 产品目标与非目标 + +### 2.1 目标 + +1. 对常见 `.jpwabc` 样例可稳定解析并显示。 +2. 保持语义对象与视觉对象分离:编辑音符不会退化成不可维护的富文本字符串。 +3. 固定 A4 页面优先,按小节折行、分页,并在歌词和附属符号存在时避免重叠。 +4. 采用 SVG 生成可缩放、可打印的谱面,而不是依赖响应式文本流或截图。 +5. 播放器至少正确处理核心音高、时值、休止、速度、拍号、声部和常见反复。 +6. 对未知 JPW-ABC 区块和暂不支持的对象给出可见提示,并尽量原样保留。 + +### 2.2 非目标 + +首期不承诺以下能力与 JPW7 完全等价: + +- 复现 JPW7 的 GDI/GDI+ 绘图调用、字体私有文件身份或窗口控件; +- 在没有证据的情况下宣称恢复 `HorzSpacing_AW`/`HorzSpacing_Gap` 精确公式、内部 `Bounds` 或所有控制码; +- 一次性覆盖动态谱视频、MP3 变速同步、MIDI 键盘录谱、外部 SF2、AI 歌声合成和全部专业配器功能; +- 用普通文本渲染替代歌词、临时声部、弧线、房子等具有排版语义的对象。 + +## 3. 版本范围 + +### 3.1 目标版(1:1 基线) + +目标版应覆盖 P0 与 P1。P2 为扩展能力,不阻塞固定页面编辑器和在线基础播放的验收。 + +| 优先级 | 功能范围 | +|---|---| +| P0 | `.jianpu.json` 工程打开/保存、核心 JPW-ABC 兼容导入/导出、单声部歌谱、标题/音符/休止/小节线/歌词、固定 A4、自动排版、SVG 预览、浏览器播放、打印/PDF | +| P1 | 手动排版、锁定间距、多段歌词、弧线/连音/装饰、特殊文字、页面文本框、多声部、连谱号、临时声部、常见反复、多声部播放及谱内变速/换拍/转调 | +| P2 | MP3 同步、动态谱视频、MIDI 导入/导出增强、MIDI 键盘录谱、外部 SF2、复杂播放记号、AI 歌声合成专用流程 | + +### 3.2 首期交付边界 + +首期必须形成一条闭环: + +`导入样例 → 解析 → 页面预览 → 编辑核心对象 → 自动排版 → 播放 → 保存工程并重开 → 兼容导出/打印` + +若某个对象在首期不能编辑,仍必须保留到工程并提示;无法可靠显示或兼容导出时明确说明或阻止对应输出,不能阻止保存完整工程。 + +### 3.3 本次评审修复的用户确认 + +用户通过两次决策确认选择: + +1. 使用独立 `.jianpu.json` 保存完整 Web 工程;`.jpwabc` 为兼容导出,不承诺承载全部 Web 布局状态。工程格式和兼容支持集见技术方案 §4.3~4.5。 +2. 谱内变速、换拍、转调纳入 R1/P1,并包括从光标开始和反复跳转时的状态恢复。原 PRD-A-008 中的中途变速移至新增 PRD-A-009;其余媒体扩展仍为 P2。 + +装饰/力度的可播放与仅视觉清单以技术方案 §7.4 的 Web 播放规则 W1 为准,不以“部分支持”由实现者临时决定,也不宣称 W1 为 JPW7 的演奏算法。 + +## 4. 功能需求 + +### 4.1 文档与文件 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-F-001 | 新建空白简谱,选择纸张、边距、默认字体和基础拍号/调号 | P0 | +| PRD-F-002 | 导入 UTF-16 `.jpwabc`;识别 `.Options`、`.Fonts`、`.Title`、`.Voice`、`.Words`、`.Attachments`、`.Page` 等已观察区块 | P0 | +| PRD-F-003 | 对未知区块、未知对象和无法解析的控制码保留原始片段,并显示兼容性提示 | P0 | +| PRD-F-004 | 导出 `.jpwabc` 兼容支持集;稳定序列化并通过 JPW7 原程序回读,未知内容安全保留,不能表示的语义明确阻止导出 | P0 | +| PRD-F-005 | 导出 SVG、PNG、PDF/浏览器打印;导出不包含编辑辅助信息 | P0 | +| PRD-F-006 | 提供文档脏状态、撤销/重做、重新加载和错误恢复 | P0 | +| PRD-F-007 | 原文件只读导入;测试和转换使用副本,保留日志与哈希 | P0 | +| PRD-F-008 | 打开/保存版本化 `.jianpu.json` 工程;完整恢复当前模型、稳定 ID、未知原文、文档设置和手动布局;未知工程版本不得静默重写 | P0/P1 | + +### 4.2 核心谱面编辑 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-E-001 | 插入、删除、替换音符和休止符;编辑音高、八度、变音和时值 | P0 | +| PRD-E-002 | 支持附点、延音/连音、三连音及常见音值组合 | P0/P1 | +| PRD-E-003 | 添加、修改、删除普通小节线、反复开始/结束、终止线、双细线和虚小节线 | P0/P1 | +| PRD-E-004 | 编辑标题、词曲作者、调号、拍号、速度和表情文字 | P0 | +| PRD-E-005 | 支持标准弧线/连音;标准弧线参与歌词对位、错误检查和播放,特殊弧线至少保持视觉效果 | P1 | +| PRD-E-006 | 支持倚音、重音、波音、滑音、震音、跳音等常见装饰对象 | P1 | +| PRD-E-007 | 支持和弦名称及可播放的柱式和弦;明确区分“仅显示”和“参与播放”的对象 | P1 | +| PRD-E-008 | 支持前奏、间奏、尾奏及括号伴奏语义;不能播放的文字对象不得伪装成可播放音符 | P1 | + +### 4.3 歌词与文字 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-T-001 | 歌词按音符锚定,跳过休止符、弧线和普通伴奏对象;支持一音多字、歌词横线和标点 | P0/P1 | +| PRD-T-002 | 支持多段歌词、段号、歌词前缀、英文和中英混合歌词 | P1 | +| PRD-T-003 | 歌词可修改、删除、移动、锁定和调整垂直位置/行距 | P1 | +| PRD-T-004 | 普通文字、特殊文字和文本框采用不同对象类型 | P1 | +| PRD-T-005 | 页面文本框使用页面坐标,不参与乐谱自动排版;可用于页眉、页脚、页码和说明 | P1 | +| PRD-T-006 | 对“谱面显示但不参与播放”的文字调号/拍号明确标识,不能当作真实音乐语义 | P1 | + +### 4.4 多声部与临时声部 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-V-001 | 将多行旋律组织为声部组,校验对应小节数量和时值,支持节拍对齐 | P1 | +| PRD-V-002 | 支持连谱号、反复圆点、贯通小节线、出头线和声部名称 | P1 | +| PRD-V-003 | 支持声部组修改、拆分、合并以及还原为单声部 | P1 | +| PRD-V-004 | 临时声部具有独立内容、锚点、位置、缩放、字体和歌词关系 | P1 | +| PRD-V-005 | 临时声部支持自动对齐主声部、手动对齐和解除自动对齐 | P1 | +| PRD-V-006 | 对跨行、空行、声部一变二和行间多声部提供明确错误提示 | P1 | +| PRD-V-007 | 首期至少支持 8 个同时播放声部;超过限制时不丢失数据并提示 | P0/P1 | + +### 4.5 固定页面排版 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-L-001 | 默认纸张为 A4,页面尺寸 210×297 mm;默认边距以已验证的 20 mm 配置为基线,并可配置 | P0 | +| PRD-L-002 | 页面、版心、页眉/页脚和乐谱区域有明确坐标系;每页生成独立 SVG viewBox | P0 | +| PRD-L-003 | 自动排版以小节为折行单位;不能为了填满宽度而拆开不可拆的小节 | P0 | +| PRD-L-004 | 自动水平间距考虑时值、音符和附件边界、歌词宽度与占位规则 | P0 | +| PRD-L-005 | 自动垂直排版考虑歌词、弧线、技法、临时声部和其他附件的四方向占位 | P0/P1 | +| PRD-L-006 | 支持左对齐、两端对齐、自动行距、段落间距和分页 | P0 | +| PRD-L-007 | 支持全局间距、单个符号间距、行距、移入/移出小节、强制换行和解锁 | P1 | +| PRD-L-008 | 手动修改的间距必须有锁定状态;解锁后才允许自动排版覆盖 | P1 | +| PRD-L-009 | 支持标准/端庄/典雅/手写等音符字形风格和独立字体角色 | P0/P1 | +| PRD-L-010 | 任何对象不得越过页面裁剪区;溢出时显示可定位的布局警告 | P0 | + +### 4.6 在线播放 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-A-001 | 使用 Web Audio API 播放,不依赖服务器端音频渲染;支持播放/暂停/停止、进度和从光标播放 | P0 | +| PRD-A-002 | 播放调度使用语义音高、时值、拍号、速度、调号/转调和休止符 | P0 | +| PRD-A-003 | 支持基础音色选择;多声部可分别设置音量、静音和独奏 | P0/P1 | +| PRD-A-004 | 按 W1 支持标准弧线/连音、倚音、单波音、重音、跳音及静态力度;滑音/震音/渐强渐弱等仅视觉对象不改变播放事件并提示边界 | P1 | +| PRD-A-005 | 支持常见反复记号、房子、DC/DS 等可解析播放路径;复杂或冲突反复必须提示 | P1 | +| PRD-A-006 | 播放指示条与当前音符/小节同步,可配置显示或隐藏,不进入打印输出 | P0 | +| PRD-A-007 | 浏览器自动播放策略被阻止时,提供用户点击启动的可解释状态 | P0 | +| PRD-A-008 | P2 支持连续动态音量/渐强渐弱、架子鼓、MP3 同步、MIDI 导出和动态谱视频;不含已归入 P1 的谱内控制事件 | P2 | +| PRD-A-009 | 支持谱内变速、换拍、转调;从光标开始和反复跳转时恢复有效速度、拍号、调号及声部静态力度 | P1 | + +### 4.7 可用性与无障碍 + +| 编号 | 需求 | 优先级 | +|---|---|---| +| PRD-U-001 | 所有编辑、播放和导出主要命令有键盘等效操作 | P0 | +| PRD-U-002 | 工具栏、对话框、错误提示和播放控制具有可访问名称与焦点顺序 | P0 | +| PRD-U-003 | 选中、锁定、未知对象、解析错误和播放状态不只依赖颜色 | P0 | +| PRD-U-004 | 编辑区与预览区分离,缩放不改变打印几何尺寸 | P0 | + +## 5. 用户主流程 + +### 5.1 导入并打印 + +1. 打开 `.jpwabc` 副本。 +2. 显示解析报告:已识别对象、未知对象、降级对象和警告。 +3. 在固定 A4 页面查看;切换编辑/排版模式。 +4. 修改标题、音符、歌词或间距。 +5. 运行布局检查,处理溢出和重叠警告。 +6. 预览打印,选择 PDF 或实体打印机。 +7. 保存 `.jianpu.json` 并重开校验完整编辑状态;需要回到 JPW7 时,另行导出 `.jpwabc`,查看支持集与版式差异报告。兼容导出不能替代工程保存。 + +### 5.2 编辑并播放 + +1. 点击谱面中的音符或小节定位光标。 +2. 点击播放;浏览器要求用户手势时先显示启动提示。 +3. 播放指示条随事件移动,页面自动滚动到当前页。 +4. 用户可暂停、停止、拖动进度或从当前光标重新播放。 +5. 选择声部独奏/静音、音色和速度。 +6. 播放结束后保持编辑光标和页面位置可恢复。 + +## 6. 产品质量目标 + +以下为本产品目标,不是 JPW7 黑盒测量结论: + +| 类别 | 目标 | +|---|---| +| 数据安全 | 已支持和未知区块不得静默丢失;保存失败不覆盖上一次有效文件 | +| 稳定性 | 解析错误只影响当前文档,不导致页面或播放器崩溃 | +| 排版 | 同一输入、同一配置得到确定性页面;无未报告的裁切和重叠 | +| 交互 | 常用编辑操作有明确选区和撤销;播放控制不会阻塞编辑 | +| 性能 | 10 页以内普通样例首次渲染目标 ≤2 秒;单次编辑后布局更新目标 ≤100 ms(参考桌面浏览器) | +| 兼容 | Chromium/Firefox 最新两个大版本支持核心编辑和播放;Safari 以打印和字体差异为单独兼容项 | +| 可追溯 | 回归测试记录输入文件哈希、配置、浏览器、页面数、警告和截图哈希 | + +## 7. 已知边界与产品处理原则 + +| 问题 | 当前结论 | 产品处理 | +|---|---|---| +| `HorzSpacing_AW`/`Gap` 精确公式 | 未恢复 | 保存为可配置表;验收先对比行为和基线截图,不宣称源码等价 | +| 同一符号的精确 `Pos/Bounds` | 未恢复 | 内部建立可测量的 SVG bounds,并在兼容报告中标记推断 | +| 私有字体文件身份 | 已从 PE 内嵌资源确认 `JpwCore`/`JPW_TaoDi`;许可和私有区语义未确认 | 仅在许可允许时使用;否则提供字体替换并记录测量差异 | +| 全局设置是否写回 `.jpwabc` | 实测不可靠 | 将编辑器设置分为文档设置和应用设置,保存前显示影响范围 | +| 最终 PDF/打印后续调用 | 实机捕获不完整 | 产品验收以浏览器打印/PDF 输出为准,JPW7 后端不作源码级承诺 | +| 高级反复、动态谱、MP3 同步 | 帮助文档有功能描述,Web 等价未实现 | 列为 P2;不可解析时保留对象并提示 | + +## 8. 成功标准 + +目标版完成必须同时满足: + +1. `康定情歌`等基线样例可导入、显示、播放和导出; +2. 16 小节/1 页/6 行等已记录的结构结果可复核,差异有报告而非静默发生; +3. 删除 `.Attachments` 或 `.Words` 的变体仍可打开,差异只体现为对应对象减少; +4. 核心编辑、自动排版、手动锁定、工程保存重开、JPW7 兼容导出回读、PDF/打印及已承诺的播放能力验收通过; +5. 任何未实现对象均可定位、可解释、可保留或明确阻止导出; +6. 验收证据、样例哈希、截图和日志均归档到项目测试目录。 + +## 9. 参考资料 + +- `docs/JPW7_实现原理与JPW-ABC语法调研.md` +- `docs/JPW7_WEB排版实现参考.md` +- `research/jpw7/evidence-matrix.md` +- `research/jpw7/final-audit.md` +- `research/jpw7/help-txt/JPW图文教程-快速入门.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样打歌谱.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样排版.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样打合唱谱.txt` +- `research/jpw7/help-txt/JPW图文教程-怎样使用临时声部.txt` +- `research/jpw7/help-txt/JPW图文教程-播放、动态谱.txt` diff --git a/docs/JPW7在线简谱编辑器-兼容性矩阵.md b/docs/JPW7在线简谱编辑器-兼容性矩阵.md new file mode 100644 index 0000000..4605060 --- /dev/null +++ b/docs/JPW7在线简谱编辑器-兼容性矩阵.md @@ -0,0 +1,182 @@ +# JPW7 在线简谱编辑器兼容性矩阵 + +> 版本:v1.1 +> +> 本矩阵用于区分“参考软件已观察到的能力”和“Web 产品承诺的支持级别”。“支持”指可显示、可编辑、可保存、可播放或可导出中的具体能力,不等于所有场景完全等价。 + +## 1. 证据和支持级别 + +### 1.1 证据等级 + +| 标记 | 含义 | +|---|---| +| F | 事实:帮助文档、样例或实机黑盒已观察到 | +| I | 实现推断:由多个行为推导出的对象/流程模型 | +| W | Web 建议:本产品的设计要求 | +| U | 未验证:资料不足,不能当作兼容承诺 | + +### 1.2 产品支持级别 + +| 级别 | 含义 | +|---|---| +| C0 | 首期必须支持;失败阻断验收 | +| C1 | 目标版支持;首期可显示并保留 | +| C2 | 后续扩展;不得静默丢失 | +| V | 仅视觉降级;可显示但不保证编辑/播放语义 | +| N | 当前不承诺;需先补充研究或确定范围 | + +## 2. 文件和区块兼容性 + +用户已确认:`.jianpu.json` 是完整工程保存格式,`.jpwabc` 是限定支持集的兼容导出。下文“保存”默认指工程保存,“导出”列的 ✓ 指 SVG/PNG/打印等谱面输出;**不自动代表同一对象可写回 JPW7**。原格式写入须按[技术方案 §4.3~4.5](JPW7在线简谱编辑器-功能规格与技术方案.md#43-工程保存契约)登记并通过外部回验。这个区分不能用于缩减 R0 的必选核心写入集。 + +| 对象/能力 | 参考事实 | Web 行为 | 证据 | 级别 | +|---|---|---|---|---| +| `.jianpu.json` 工程 | Web 自有格式,不是 JPW7 格式 | 版本校验;完整模型/未知原文/稳定 ID/手动布局恢复,派生缓存重建 | W | C0;C1 状态随功能加入 | +| UTF-16 `.jpwabc` | 样例为 UTF-16 文本 | 自动检测 BOM/编码并保留编码信息 | F | C0 | +| `.jpwabc` 兼容写出 | 私有字段映射不完整 | 核心集必选且须 JPW7 v7.30 回读;其余逐项证据准入,语义不能表示时阻止兼容导出但允许工程保存 | W | C0 | +| `.Options` | 含页面、间距、歌词、垂直定位等配置 | 识别已知字段;未知字段原样保留 | F/W | C0 | +| `.Fonts` | 含多个字体角色 | 建立角色映射,缺失字体给警告 | F | C0 | +| `.Title` | 含标题、作者、调号、拍号、速度等 | 结构化编辑并参与布局/播放 | F | C0 | +| `.Voice` | 含旋律/声部内容 | 解析为声部、段落、小节和基本符号 | F/I | C0 | +| `.Words` | 歌词可独立存在,删除后样例仍能打开 | 对齐到符号;缺失时保留空歌词状态 | F | C0 | +| `.Attachments` | 附属对象可独立存在,删除后样例仍能打开 | 解析支持对象;未知对象保留原始块 | F | C1 | +| `.Page` | 页面对象/文本框可独立参与页面显示 | 以页面坐标渲染,不参与乐谱流 | F/I | C1 | +| 未知区块 | 未能恢复全部控制码 | 显示兼容警告;保存时保留 | U/W | C0 | +| 空区块/缺失可选区块 | 变体文件可正常加载 | 采用默认值,不因缺失区块崩溃 | F/W | C0 | +| 重复保存 | Web 行为尚未实现 | 语义等价;格式化差异可接受 | W | C0 | + +## 3. 核心谱面兼容性 + +| 功能 | 显示 | 编辑 | 播放 | 导出 | 证据/限制 | 级别 | +|---|---:|---:|---:|---:|---|---| +| 普通音符 | ✓ | ✓ | ✓ | ✓ | 基本语义 | C0 | +| 休止符 | ✓ | ✓ | ✓ | ✓ | 需参与歌词跳过和时间线 | C0 | +| 音高/八度/变音 | ✓ | ✓ | ✓ | ✓ | 音高需结合调号和转调 | C0 | +| 基础时值/附点 | ✓ | ✓ | ✓ | ✓ | 时值影响间距和播放 | C0 | +| 音值组合 | ✓ | ✓ | ✓ | ✓ | 复杂组合需单独回归 | C1 | +| 普通小节线 | ✓ | ✓ | — | ✓ | 折行边界 | C0 | +| 反复开始/结束 | ✓ | ✓ | ✓ | ✓ | 需生成播放路径 | C1 | +| 终止/双细线/虚线 | ✓ | ✓ | 部分 | ✓ | 多为视觉语义 | C1 | +| 标准弧线/连音 | ✓ | ✓ | ✓ | ✓ | 参与歌词、检查和播放 | C1 | +| 特殊弧线 | ✓ | 部分 | — | ✓ | 参考文档明确语义不同 | V/C1 | +| 三连音/多连音 | ✓ | ✓ | ✓ | ✓ | 时值和视觉连线需一致 | C1 | +| 倚音组、重音、单上/下波音 | ✓ | ✓ | ✓ | ✓ | W1:确定音高/分数时值或力度;未知变体不可猜测 | C1 | +| 跳音 | ✓ | ✓ | ✓ | ✓ | W1:gate 为谱面时值一半,后续起点不提前 | C1 | +| 滑音、震音、复式波音 | ✓ | 已识别类型可编辑 | — | ✓ | R1 仅视觉并提示;实际演奏 P2,不修改音符事件 | C1/V,播放 C2 | +| 静态力度 pp/p/mp/mf/f/ff | ✓ | ✓ | ✓ | ✓ | W1:声部持续力度,可叠加重音/跳音 | C1 | +| 渐强渐弱/连续动态力度 | ✓ | 已识别类型可编辑 | — | ✓ | R1 仅视觉并提示;连续音量曲线 P2 | C1/V,播放 C2 | +| 和弦名称 | ✓ | ✓ | — | ✓ | 名称本身不自动发声 | C1 | +| 柱式和弦 | ✓ | ✓ | ✓ | ✓ | 作为多个音符事件编译 | C1 | +| 伴奏括号/过门 | ✓ | ✓ | ✓ | ✓ | 需区分主旋律与过门 | C1 | +| 速度/表情 | ✓ | ✓ | 速度参与;纯表情不参与 | ✓ | 初始控制 C0;谱内控制 C1 | C0/C1 | +| 中途变速/转调 | ✓ | ✓ | ✓ | ✓ | 用户确认 R1 必选;恢复光标/反复路径的有效控制状态 | C1 | +| 换拍 | ✓ | ✓ | ✓ | ✓ | R1 必选;时间线和布局均需读取 | C1 | +| 换气记号 | ✓ | ✓ | — | ✓ | 主要视觉/演奏提示 | C1 | + +装饰播放的参数、支持组合和拒绝条件以[技术方案 §7.4 W1](JPW7在线简谱编辑器-功能规格与技术方案.md#74-装饰与力度播放规则-w1)为唯一契约,验收对应 ACC-A-021~023。W1 是明确的 Web 规则,不是对原程序演奏算法或音色完全相同的声明。 + +## 4. 歌词、文字和附件 + +| 功能 | 参考行为 | Web 目标 | 证据 | 级别 | +|---|---|---|---|---| +| 歌词锚定 | 对齐音符,跳过休止符、弧线和普通伴奏 | 语义锚定,自动防重叠 | F | C0/C1 | +| 多段歌词 | 支持段号、前缀和多段 | 每段独立编辑和排版 | F | C1 | +| 一音多字 | 帮助文档列为歌词能力 | 保留音节拆分关系 | F/W | C1 | +| 歌词横线/节奏线 | 帮助文档列为歌词能力 | 作为歌词附件排版 | F/W | C1 | +| 英文歌词 | 支持英文和中英混合 | 按音节规则或手动锚定 | F/W | C1 | +| 歌词锁定 | 可锁定歌词位置 | 解锁后自动重排 | F | C1 | +| 普通文字 | 可与音符并列 | 流式文字对象 | F/I | C1 | +| 特殊文字 | 可移动、缩放并配置占位 | 锚定附件,可选择四向占位 | F/I | C1 | +| 页面文本框 | 页眉、页脚、页码等 | 页面对象,不参与乐谱流 | F/I | C1 | +| 指法/扩展符号 | 器乐谱常用 | 支持已知类型,未知降级 | F | C1/V | +| 锣鼓经 | 外部/内部字符、收藏夹和打击乐语义 | 首期可视化;播放按已知映射 | F | C1/C2 | +| 临时声部 | 可自动/手动对齐,可带歌词和字体 | 独立声部片段 | F/I | C1 | +| 页码/页眉/页脚 | 页面对象可模拟 | 页面模板或文本框 | F/W | C1 | + +## 5. 多声部和布局兼容性 + +| 功能 | 参考行为 | Web 目标 | 证据 | 级别 | +|---|---|---|---|---| +| 单声部自动排版 | 默认自动排版 | P0 基线 | F/W | C0 | +| 按小节折行 | 超宽时移动小节到下一行 | 不拆分不可拆小节 | F | C0 | +| A4 | 实测页面为 210×297 mm | 固定 SVG/打印纸张 | F | C0 | +| 默认边距 | 实测默认边距为 20 | 以可配置基线实现 | F | C0 | +| 四向占位 | 附件边界影响相邻符号/行高 | 布局前测量并应用占位 | F/I | C0/C1 | +| 自动水平间距 | 时值、歌词和附件共同影响 | `SpacingProfile` 可配置 | F/I/W | C0 | +| 自动垂直间距 | 歌词、附件和行距共同影响 | 行高由 bounds 和占位计算 | F/I | C0 | +| 两端对齐 | 通过拉伸音符间距对齐右边界 | 可切换且可回归 | F | C0 | +| 手动单符号间距 | 可任意调整并标记锁定 | 锁定覆盖自动值,完整持久化到工程;不默认承诺 JPW7 写回 | F/W | C1 | +| 解锁 | 可恢复自动排版 | 命令级解锁 | F | C1 | +| 强制换行 | 可插入回车/移动小节 | 工程保存恢复手动分行;兼容导出另报告能否携带 | F/W | C1 | +| 行距/段落间距 | 可全局或单行调整 | 样式与局部覆盖分离 | F | C1 | +| 多声部节拍对齐 | 小节数和对应时值需一致 | 校验后对齐并锁定关系 | F | C1 | +| 连谱号和声部名称 | 多声部常见结构 | 可编辑页面附件 | F | C1 | +| 声部一变二 | 临时声部/隐藏主声部实现 | 视觉、播放分离处理 | F/I | C1/C2 | +| 精确 AW/Gap 公式 | 尚未恢复 | 不作源码级承诺 | U | N | +| 精确 `Pos/Bounds` | 尚未恢复 | 使用 SVG 测量模型 | U/I | N | +| 所有私有字体 | `JpwCore`/`JPW_TaoDi` 资源身份已确认,但全部私有区语义和许可未确认 | 映射/替代并告警 | I/U | N/V | + +## 6. 播放兼容性 + +| 能力 | 参考软件描述 | Web 目标 | 级别 | +|---|---|---|---| +| 播放/暂停/停止 | 播放窗口提供 | Web Audio 控制器 | C0 | +| 播放进度拖动 | 播放窗口提供 | 按语义时间定位 | C0 | +| 从光标播放 | 支持从光标和点按跳播 | 点击音符后开始 | C0 | +| 基础音色 | 上百种音色描述 | 首期提供有限内置音色;音色数量不作等价承诺 | C0/C1 | +| 多声部合奏 | 普通版至多 8,创作版至多 13 的文档描述 | 首期至少 8 声部播放;声部组织编辑在 R1 | C0/C1 | +| 声部独奏/静音 | 播放选项支持 | 播放器可切换 | C0/C1 | +| 速度/拍号/调号 | 支持换拍、变速、转调 | 初始控制 R0;谱内控制与跳转状态恢复 R1(PRD-A-009) | C0/C1 | +| 倚音/重音/单波音/跳音/静态力度 | 参考记号存在,全部演奏细节不作等价承诺 | 按 W1 逐项与支持组合测试,不再用未枚举的“部分播放”代替 | C1 | +| 标准反复 | 支持常见反复 | 解析为确定播放路径 | C1 | +| 复杂自定义反复 | 创作版支持任意顺序 | P2;无法解析时提示 | C2 | +| 播放指示条 | 可配置颜色/样式 | 屏幕辅助层,打印隐藏 | C0 | +| 架子鼓/锣鼓经 | 帮助文档描述 | P2 或已知映射的有限支持 | C2 | +| 动态力度/渐强渐弱 | 创作版描述 | P2 | C2 | +| MP3 同步 | 需要同步点/测速分析 | P2,需另建音频资源流程 | C2 | +| MIDI 导出 | 帮助文档描述 | P2 | C2 | +| AI 歌声合成 MIDI | 帮助文档描述 | 不纳入首期 | C2 | +| 动态谱视频 | 帮助文档描述 | 不纳入首期 | C2 | + +## 7. 导出和打印兼容性 + +| 输出 | 目标 | 现阶段承诺 | +|---|---|---| +| `.jianpu.json` | 完整 Web 工程保存/重开,含未知原文和手动状态 | C0;C1 的完整状态随功能验收 | +| `.jpwabc` | 核心兼容集原程序回读;不能承载的 Web 版式预先确认,语义不安全则阻止 | C0;其他写入能力逐项准入,不代表全部 C1 可写 | +| SVG | 一页固定 viewBox,可继续编辑/检查 | C0 | +| PNG | 固定 DPI、无辅助层 | C0 | +| PDF | 浏览器打印生成固定 A4 | C0;具体浏览器打印设置需验收 | +| 实体打印 | 由浏览器打印对话框选择设备 | C0;不复制 JPW7 GDI 后端 | +| Word/PPT | JPW7 帮助文档提到复制/导出后再排版 | C2;不作为首期源格式 | +| MIDI | 需完整播放时间线 | C2 | +| MP3/动态谱 | 需音频/视频编码链路 | C2 | + +## 8. 浏览器兼容性目标 + +这是产品测试目标,不是当前实测结论。 + +| 环境 | 固定 SVG 预览 | 编辑 | Web Audio 播放 | 打印/PDF | 备注 | +|---|---:|---:|---:|---:|---| +| Chromium 最新两个版本 | 必须 | 必须 | 必须 | 必须 | 主验收环境 | +| Microsoft Edge 最新两个版本 | 必须 | 必须 | 必须 | 必须 | Windows 打印重点环境 | +| Firefox 最新两个版本 | 必须 | 必须 | 必须 | 必须 | 检查字体测量和打印差异 | +| Safari 最新两个版本 | 目标 | 目标 | 目标 | 目标 | 需单独验证字体和打印分页 | +| 移动端 Chromium | 只读/播放目标 | 非首期承诺 | 目标 | 不承诺 | 固定页面可缩放查看 | +| 移动端 Safari | 只读/播放目标 | 非首期承诺 | 目标 | 不承诺 | 受音频和下载策略影响 | + +## 9. 当前未验证清单 + +以下项目不能在兼容性宣传中写成“已复刻”: + +1. `HorzSpacing_AW`、`HorzSpacing_Gap` 的精确公式和全部字段含义; +2. 同一符号的官方 `Pos`、`Bounds` 数值及其与歌词/附件的精确关系; +3. JPW7 锁定间距、版心宽度和自定义页面宽度的完整持久化规则;Web 自身已选定工程保存,不受此边界阻塞; +4. `.Options` 全局设置是否写回 `.jpwabc`; +5. 私有区字形到语义对象的完整映射、字体许可和 Web 部署边界; +6. `GdipAddPathString` 调用与具体语义对象的完整映射; +7. JPW7 已有 Microsoft Print To PDF A4 单页文件证据;打印后续 `StartPage/EndPage`、EMF、多页序列的完整证据仍未获得; +8. 屏幕布局和打印布局是否共享全部缓存; +9. 所有高级播放记号、MP3 同步和动态谱视频在 Web 端的等价行为。 + +遇到以上内容时,产品应显示“兼容性未验证/按 Web 方案处理”,不能将推断当作参考软件事实。 diff --git a/docs/JPW7在线简谱编辑器-功能规格与技术方案.md b/docs/JPW7在线简谱编辑器-功能规格与技术方案.md new file mode 100644 index 0000000..ccd65ad --- /dev/null +++ b/docs/JPW7在线简谱编辑器-功能规格与技术方案.md @@ -0,0 +1,402 @@ +# JPW7 在线简谱编辑器功能规格与技术方案 + +> 版本:v1.1 +> +> 本文是产品需求的可实现规格。凡标注“Web 方案”的内容是新产品设计,不是 JPW7 源码事实。 + +## 1. 设计原则 + +1. **语义优先**:先解析为音符、歌词、附件、声部和页面对象,再排版和绘图。 +2. **排版与渲染分离**:布局引擎只输出位置、尺寸、页码和可见性;SVG 渲染器负责绘制。 +3. **固定纸张优先**:默认 A4 210×297 mm,页面不是响应式长文本;缩放只影响屏幕显示,不改变打印几何。 +4. **行为优先**:以帮助文档、样例和黑盒观察到的用户行为为兼容基线;没有证据的内部公式保留为可调参数。 +5. **可逆编辑**:所有编辑命令都能撤销;未知 JPW-ABC 内容不能因为一次保存而静默消失。 +6. **播放独立**:播放时间线从语义模型生成,不从 SVG 像素位置反推音符时值。 + +## 2. 总体架构 + +```text +.jpwabc / 新建文档 + │ + ▼ + Utf16Decoder + SectionParser + │ + ▼ + JPW-ABC AST + UnknownBlock[] + │ 编辑命令 / 撤销重做 + ▼ + ScoreModel ← ProjectCodec ← .jianpu.json + ├─ BasicSymbol[] + ├─ VoiceGroup[] / Measure[] + ├─ LyricVerse[] + ├─ AnchoredAttachment[] + ├─ PageObject[] + ├─ DocumentStyle / FontRoles + └─ CompatibilityReport + │ + ├──────────────┐ + ▼ ▼ + LayoutEngine PlaybackCompiler + │ │ + ▼ ▼ + LayoutSnapshot PlaybackTimeline + │ │ + ▼ ▼ + SVGRenderer Web Audio Scheduler + │ + ├─ 屏幕预览 + ├─ SVG/PNG + └─ 浏览器打印/PDF +``` + +### 2.1 模块边界 + +| 模块 | 职责 | 不负责 | +|---|---|---| +| `FileCodec` | JPW-ABC 解码、区块识别、兼容序列化、未知区块保留 | 将 Web 布局伪装成私有控制码 | +| `ProjectCodec` | `.jianpu.json` 版本校验、完整工程读取/保存 | 把工程数据当 JPW-ABC 重解析或保存派生缓存 | +| `Parser` | 将 JPW-ABC 控制码和区块转为 AST | 直接画 SVG | +| `ScoreModel` | 维护稳定 ID、引用关系、编辑状态和语义校验 | 计算最终像素位置 | +| `CommandStore` | 执行编辑命令、撤销/重做、脏状态 | 解析文件格式 | +| `LayoutEngine` | 测量、占位、行分配、分页、手动锁定 | 播放声音 | +| `LayoutSnapshot` | 固化一次布局结果,供渲染、命中测试和回归比较 | 反向修改语义模型 | +| `SVGRenderer` | 将布局对象画成 SVG,区分辅助层和打印层 | 决定音符时值 | +| `PlaybackCompiler` | 将模型编译为音符事件、控制事件、反复路径 | 读取 SVG 文本 | +| `AudioEngine` | Web Audio 调度、暂停、跳转、音色和指示条同步 | 修改谱面 | +| `ExportService` | 兼容预检、导出 JPW-ABC、SVG、PNG、打印/PDF | 偷删内容或替代工程保存 | + +## 3. 语义数据模型 + +以下是 Web 方案的最小模型。名称用于说明责任边界,不代表 JPW7 内部类名。 + +### 3.1 文档层 + +```text +ScoreDocument +├─ documentId +├─ sourceEncoding +├─ sections: Section[] +├─ voices: VoiceGroup[] +├─ title: TitleBlock +├─ styles: DocumentStyle +├─ pageObjects: PageObject[] +├─ unknownBlocks: UnknownBlock[] +├─ manualLayout: 按稳定 ID 绑定的用户布局覆盖 +├─ playbackPolicyVersion: 1 +└─ compatibility: CompatibilityReport +``` + +| 对象 | 关键字段 | 说明 | +|---|---|---| +| `TitleBlock` | 标题、作者、调号、拍号、速度、表情 | 既有帮助文档确认标题类信息参与播放/显示的区别 | +| `VoiceGroup` | `voiceId`、行、声部名称、连谱号、对齐状态 | 多声部的组织单位 | +| `Measure` | 小节 ID、符号范围、时值总量、小节线类型 | 折行和播放路径的基本单位 | +| `UnknownBlock` | 原始文本、所属区块、位置、警告 | 未实现内容的可逆保留 | +| `PageObject` | 文本/图像/文本框、页面坐标、字体、层级 | 不进入乐谱流式排版 | + +### 3.2 基本符号 + +`BasicSymbol` 至少包含: + +- 稳定 `symbolId`; +- `kind`: `note`、`rest`、`barline`、`space`、`chord` 等; +- 音高、八度、变音记号; +- 基础时值、附点、连音/延音关系; +- 所属声部、小节和段落; +- 关联歌词和附件 ID; +- 播放语义状态与仅视觉状态。 + +同一符号的“原始控制码”“规范化语义”“布局结果”必须分开保存。这样可避免修改字号或间距时破坏音乐数据。 + +### 3.3 附属对象 + +`AnchoredAttachment` 通过 `anchorId` 绑定一个或多个基本符号,至少包含: + +- 类型:弧线、房子、技法、力度、歌词、和弦、扩展符号、特殊文字、临时声部等; +- 起止锚点或单一锚点; +- 相对位置、缩放、字体角色、颜色; +- 上/下/左/右占位标志; +- 是否参与播放、歌词对位和错误检查; +- 跨行显示策略。 + +帮助文档明确区分标准弧线与特殊弧线:标准弧线可参与歌词对位、错误检查和播放,特殊弧线不具备相同语义。模型必须保留这一区别。 + +### 3.4 歌词 + +`LyricVerse` 不直接存成页面文本,而应包含: + +- 段号和前缀; +- 音节数组及每个音节的锚点; +- 一音多字、歌词横线、标点和英文分词状态; +- 字体、字号、垂直位置、行距、锁定状态; +- 对休止符、弧线、伴奏对象的跳过规则。 + +### 3.5 临时声部 + +临时声部是独立的附属旋律,不是普通字符串。模型至少需要: + +- 内部 `VoiceFragment`; +- 关联主声部和起始锚点; +- 自动对齐/手动对齐状态; +- 可选歌词、倚音、和弦和独立字体; +- 位置、宽度、垂直占位和播放通道模式。 + +## 4. JPW-ABC 解析和保存 + +### 4.1 输入处理 + +1. 检测并解码 UTF-16 文本;保留 BOM/编码信息。 +2. 按区块读取 `.Options`、`.Fonts`、`.Title`、`.Voice`、`.Words`、`.Attachments`、`.Page` 等已观察内容。 +3. 对每个区块产生源位置,便于错误定位。 +4. 先建立符号 ID 和锚点,再解析歌词、附件和页面对象。 +5. 对未识别控制码和未实现区块建立 `UnknownBlock`,不能直接丢弃。 + +### 4.2 解析报告 + +导入后必须显示: + +- 成功识别的区块和对象数; +- 被降级为视觉对象的对象数; +- 未识别控制码、缺失字体、异常声部和时值不一致; +- 是否允许保存和导出; +- 可跳转到源片段的错误位置。 + +### 4.3 工程保存契约 + +用户已确认采用**单个 UTF-8 `.jianpu.json` 工程文件**,不是 `.jpwabc` 加伴随文件。菜单“保存/另存为”保存工程;“导出 JPW7 文件”是独立命令。 + +最小封装为 `format: "jianpu-web"`、`schemaVersion: 1`、`source`、`score`: + +- `source`:导入原文件的名称、编码/BOM、SHA-256 和原始字节的 Base64;新建文档为 `null`。它只用于溯源,不能覆盖已经编辑的模型。 +- `score`:当前 `ScoreDocument` 的可持久化字段,包括 AST/源绑定、未解释片段的原文/顺序/上下文、稳定 ID、音乐语义、歌词/附件/声部关系、文档样式和页面对象;当前模型是编辑结果的唯一权威来源。 +- `score.manualLayout`:按稳定对象/小节 ID 记录用户设置的间距覆盖与锁定、强制分行、行距/段落覆盖、歌词位置/锁定、临时声部手动对齐/位置/缩放。每个属性只存一份;歌词和附件引用这里的布局覆盖,不复制另一套值。 +- `score.playbackPolicyVersion: 1`:采用 §7.4 的 W1 演奏规则,避免未来默认规则变化悄悄改变已保存工程。 +- 不保存派生的 `LayoutSnapshot`、SVG、兼容性/错误报告、音频节点、应用偏好或撤销栈;重开后重新校验并重建派生结果,撤销历史从新会话开始。字体只存角色/引用与设置,不自动打包未获授权字体。 + +同一版本的未编辑工程规范化保存后字节稳定;重开保持稳定 ID、未知内容和全部用户设置。在相同字体与布局版本下,重新计算的版式一致;字体替代须明确告警。 + +打开时先校验格式标识、版本、字段类型、输入上限和引用关系。未知 `schemaVersion`/`playbackPolicyVersion`、未支持的工程字段或损坏结构拒绝进入可编辑状态,原文件仍可保留;不可假装迁移成功后覆盖。`unknownBlocks` 中的未知 JPW-ABC 是合法保留数据,不属于未知工程字段。孤立附件允许以明确的孤立状态保存在工程中;未标记的悬空引用为错误。 + +工程保存不依赖 JPW7 字段映射。未知内容关联有歧义时保留原文和孤立/未决状态并诊断,限制相关编辑、播放或兼容导出,不能因此丢掉完整工程保存出口。成功兼容导出不清除“工程未保存”状态;浏览器本地草稿只是恢复辅助,不能代替可携带的工程文件。 + +### 4.4 JPW7 兼容导出契约 + +`.jpwabc` 使用原格式编码与区块,不写 Web 自造控制码,也不宣称能恢复全部 Web 编辑状态。兼容输出先对照 §4.5 的支持集: + +1. 对已验证写入映射的语义序列化;未改动且关联上下文安全的未知原文按顺序保留。 +2. 无法可靠表示的音乐、歌词、附件语义或已改动的未知锚点,**阻止 `.jpwabc` 导出**并定位问题;不阻止正常工程保存,不自动删除对象。 +3. 只有 Web 专用版式无法携带时,预先列出会丢失的锁定/强制分行/位置等覆盖及可能的重排影响;用户明确确认后才导出无这些覆盖的副本。取消无副作用,原工程不变,未受支持的原始字段不能顺手重置。 +4. 若版式变更影响未知对象关联,按语义不安全处理,不能套用上一条的视觉差异豁免。 +5. Web 再导入与 **JPW7 v7.30 原程序回读**是两个独立检查;自我 round-trip 成功不证明原程序兼容。逐像素排版和 W1 演奏细节不在原程序等价承诺内。 + +### 4.5 原格式写入支持集与外部回验 + +- **R0 必选核心集**:新建/修改单声部核心音符与休止、八度/变音、基础时值/附点、普通/终止小节线、标题/词曲作者、初始调号/拍号/速度、单段歌词及有已知映射的 A4 页面/字体角色字段。不得因回验失败把必选项临时移出支持集。 +- **R1 与扩展原格式能力**:R1 的全部承诺先保证工程保存、编辑、显示及相应播放。C1 不自动等于可写回 JPW7;新增原格式写入能力必须逐项登记并通过同样的外部回验,才可标为兼容导出支持。Web 手动覆盖默认仅存工程,除非某项已有明确写入映射和通过证据。 +- M0 建立写入支持清单,至少记录:模型属性、JPW 控制码/字段或区块、允许值/上下文、里程碑、测试 fixture、原程序结果、证据路径。状态只能是必选待实现、已验证或不支持;保留原始字节不等于支持编辑写出。既定核心集与用户批准的新增承诺不能靠改清单缩减。 +- 外部回验必须覆盖:全新 Web 文档、修改既有标题、音高/时值/歌词编辑、包含 Web 未解释但原程序认识的原文保留案例;期望由人工可核算输入或独立原程序样例确定,不能由被测序列化器自生成。 +- 在固定 JPW7 版本中打开 Web 输出,检查是否报错/自动修复、标题、音符/小节/歌词及适用的基本播放;再由 JPW7 另存副本,在 Web 比较承诺的语义与字段。仅检查“窗口能打开”不算通过;不要求原程序另存后字节完全相同。 +- 记录原程序版本/二进制哈希、Web 输出及原程序另存文件哈希、操作和比较证据;不能使用原程序时标未执行并阻止相应兼容承诺发布。优先 Windows MCP,必要时按项目规范使用 SSH;这是实现回归,不是重启泛化调研。 + +## 5. 排版引擎 + +### 5.1 布局阶段 + +```text +1. 解析页面配置和字体角色 +2. 按声部/段落/小节建立流 +3. 测量基本符号、歌词和附件边界 +4. 计算四方向占位 +5. 计算自动水平间距 +6. 按小节分配谱行 +7. 计算行高、段落间距和页内位置 +8. 应用锁定间距、强制换行和手动行距 +9. 分页并生成页眉/页脚/页面对象 +10. 生成 LayoutSnapshot 和布局警告 +``` + +### 5.2 间距规则 + +已确认的行为规则: + +- 相邻基本符号用参考点定义间距; +- 自动水平间距主要受时值影响,时值越大通常需要更宽空间; +- 歌词和附属对象的边界会撑开音符间距以避免重叠; +- 自动排版按小节折行;过宽时移动小节,而不是任意切开小节; +- 手动改变的音符间距具有锁定状态,解锁后可恢复自动排版; +- 两端对齐通过增加段内音符间距使行末接近右边界。 + +`HorzSpacing_AW`、`HorzSpacing_Gap` 的精确公式和所有字段含义尚未恢复。因此实现采用 `SpacingProfile`: + +```text +SpacingProfile +├─ durationWeight +├─ baseGap +├─ lyricGap +├─ attachmentGap +├─ minSymbolGap +├─ presetName +└─ lockedOverrides +``` + +预设名称可以映射已观察的“很松/标准/很紧/等距”等行为,但在公式未证实前不应声称数值等价。 + +### 5.3 占位和溢出 + +每个对象先产生 `Bounds` 和四向占位信息,再参与相邻符号和下一行位置计算。默认处理: + +- 歌词上下始终占位,水平使用防重叠算法; +- 标准房子、弧线和技法按其方向占位; +- 特殊弧线、特殊房子、渐强渐弱和临时声部可配置垂直占位; +- 连谱号产生水平占位; +- 页面文本框不占用乐谱流空间; +- 任何对象越过裁剪区都生成布局警告,不能静默裁切。 + +### 5.4 LayoutSnapshot + +快照是渲染、命中测试和视觉回归的唯一输入,至少包含: + +- `pageSizeMm`、`contentRectMm`; +- 页、段落、谱行、小节和声部的层级; +- 每个对象的 `x/y/width/height`、基线、页码、层级和可见性; +- 对象与语义 ID 的映射; +- 辅助信息层和打印层开关; +- 产生的重叠、溢出、缺失字体和降级警告。 + +## 6. SVG 和字体 + +### 6.1 SVG 输出 + +- 一页一个固定 SVG; +- 页面尺寸以 mm 语义表达,内部可使用稳定的高精度坐标; +- 符号、歌词、弧线、文本框和辅助层使用独立 `` 分组; +- 每个可编辑对象带 `data-symbol-id`/`data-attachment-id`,用于命中和指示条; +- 打印导出关闭选区、锁定星号、版心线和调试框; +- 不能通过截图替代 SVG,因为截图无法保证矢量打印和对象命中。 + +### 6.2 字体角色 + +至少分离: + +- 音符字体; +- 标题字体; +- 歌词字体; +- 普通文字字体; +- 特殊文字/表达记号字体; +- 声部名称字体; +- 扩展符号字体。 + +JPW7 样例中存在 Arial、微软雅黑、宋体、仿宋、楷体、幼圆、黑体等角色,以及专用字体角色。Web 端必须处理字体许可、加载失败和替代字体导致的测量变化,并在报告中记录实际字体。 + +## 7. 播放系统 + +### 7.1 编译流水线 + +```text +ScoreModel + → RepeatResolver + → VoiceTimeline[] + → Tempo/TimeSignature Map + → Pitch/Duration Events + → AudioEngine Scheduler +``` + +播放事件至少包含: + +- 绝对拍位置和持续时长; +- 声部 ID、音高、力度和音色; +- 小节/符号/页面映射; +- 速度、拍号、转调和控制变化; +- 播放指示条的目标对象。 + +### 7.2 基础播放行为 + +- Web Audio `AudioContext` 必须由用户手势启动; +- 播放调度采用短窗口预调度,暂停/跳转时取消后续事件; +- 播放位置以语义时间为准,不以 SVG x 坐标为准; +- 从光标播放需从当前符号/小节对应的时间点开始; +- 播放指示条可只显示在屏幕辅助层,不能进入打印层; +- 多声部默认合奏,支持单独静音、独奏和音量调整; +- 解析失败的播放对象必须在开始前提示,不得生成静默错误音符。 + +### 7.3 反复和特殊对象 + +- 标准房子、常见反复、DC/DS 等先编译成播放路径; +- 多声部反复优先使用第 1 声部的控制信息,若出现冲突则报告; +- 复杂自定义反复列为高级播放能力,不能用近似顺序冒充精确结果; +- 和弦名称只显示不自动发声,只有明确的音符/柱式和弦对象才进入时间线; +- 谱内变速、换拍、转调为 R1 必选;从光标开始和反复跳转时按目标路径恢复速度、拍号、调号及声部静态力度; +- 特殊弧线与标准弧线在播放语义上分开,具体装饰边界按下面的 W1 清单。 + +### 7.4 装饰与力度播放规则 W1 + +这是 **Web 产品的确定性演奏规则,不是 JPW7 实测算法**。R1 必须实现表中可播放项;R0 可保留并提示未支持,不能把无效果播放标为 R1 通过。 + +四分音符为 1 拍,`D` 为该主音的谱面时值;音符事件分别记录 `startBeat`、`scoreDurationBeats`、`gateBeats`、`velocity`。`gateBeats` 仅控制停止发声时刻,不能改变下一个音的谱面起点。默认力度 80、普通音 gate 等于 D;音色引擎须消费力度,不能只把数值留在日志中。 + +| 类型 | R1 播放契约 | 保留/异常规则 | +|---|---|---| +| 标准连音/延音 | 同音高延音链合并发声,时值相加;异音高连奏保留各次起音与谱面时值,前音结束与后音开始衔接 | 特殊视觉弧线不能混入延音链 | +| 倚音组 | 明确的 n 个倚音顺序占用主音的 D/4,各为 D/(4n);主音占后 3D/4;下一谱面音的起点不移动 | 不从装饰字形猜音高;缺少音高/主音锚点时诊断并阻止不可靠播放 | +| 单上/下波音 | 主音→明确邻音→主音,时值依次 D/4、D/4、D/2;总时值 D | 邻音由有效调号/变音或显式设置解析成确定音高;无确定邻音不得猜测。复式/未分类波音仅视觉并提示 | +| 重音 | 当前音的力度加 20,上限 127;不改变音高、谱面时值或后续力度状态 | 柱式和弦的重音作用于该起点所有音 | +| 跳音 | `gateBeats = D/2`,谱面占时仍为 D,后半为发声间隙 | 不能把小节总时值缩短或让后续音提前 | +| 静态力度 pp/p/mp/mf/f/ff | 对应 32/48/64/80/96/112;作用于锚定声部,自该位置持续到下一条静态力度;先设置静态力度,再应用重音 | 跳转/反复后恢复目标路径的力度;其他声部不受影响 | +| 滑音、震音、复式波音、渐强渐弱/连续动态力度 | 仅视觉;原文和形状保留,显示播放降级提示;增删这些标记不得改变已有音符事件 | 实际演奏列 P2,不用猜测的弯音/重复音/音量曲线冒充支持 | +| 换气、和弦名称、特殊弧线、纯表情文字 | 仅视觉/演奏提示,不生成或修改音符事件 | 不把换气自动插成休止,也不让文字自动发声 | + +静态力度、重音、跳音的组合必须可用,处理顺序为静态力度→重音限幅→跳音 gate;例如 `f + 重音 + 跳音` 得到力度 116、gate D/2。倚音与单波音分别支持独立使用及静态力度。其他多装饰叠加、未定义类型或与延音冲突时保留并给出具体诊断,阻止不可靠播放,不限制工程保存;不得以此拒绝表中已明确承诺的单项/组合。原始 JPW 记号到规范类型的映射须留证据,不确定记号归为未知,不能无提示套用 W1 默认类型。 + +量化用例见[验收标准 §7.2](JPW7在线简谱编辑器-验收标准.md#72-w1-播放金样)。预期从本表人工计算,不能调用生产编译器生成金样。 + +## 8. 导出、打印和存储 + +| 输出 | 方式 | 要求 | +|---|---|---| +| `.jianpu.json` | `ProjectCodec` | 完整工程保存/重开,含未解释内容、文档状态与手动布局;不是打印产物 | +| `.jpwabc` | `FileCodec` | 按 §4.4~4.5 兼容导出;支持集通过原程序回读,未映射版式须确认差异,未映射语义阻止导出 | +| SVG | `SVGRenderer` | 一页一个固定尺寸 SVG,保留对象 ID | +| PNG | 浏览器栅格化 | 采用明确 DPI;不作为源文件 | +| PDF | 浏览器打印或客户端打印 | 固定纸张、缩放 100%、关闭页眉页脚和辅助层 | +| 实体打印 | 浏览器打印对话框 | 由用户选择打印机;产品不假定 JPW7 的 GDI 后端 | +| MIDI/MP3/视频 | P2 | 需单独声明浏览器兼容和授权边界 | + +## 9. 错误、降级和可追溯性 + +### 9.1 错误级别 + +- **阻断**:无法解码、核心语法损坏、页面无法生成;禁止静默导出。 +- **警告**:未知对象、字体替换、时值不一致、复杂反复降级;允许预览但须可见。 +- **提示**:浏览器自动播放被阻止、打印设置可能改变、使用默认音色等。 + +### 9.2 日志字段 + +每次导入、布局、播放和导出记录: + +- 输入文件 SHA-256; +- 应用版本、浏览器和操作系统; +- 页面配置、字体实际加载结果、间距预设; +- 对象计数、页数、谱行数、警告和错误; +- 输出文件 SHA-256; +- 视觉回归截图或 SVG 快照哈希。 + +## 10. 测试分层 + +1. **Codec 测试**:工程版本/结构与完整恢复;JPW 编码、区块、未知块、稳定序列化及独立原程序回读。 +2. **Model 测试**:编辑命令、锚点、歌词对位、声部校验。 +3. **Layout 测试**:小节折行、分页、占位、锁定间距、字体替换。 +4. **Render 测试**:SVG 结构、对象 ID、打印层、截图回归。 +5. **Playback 测试**:事件时间线、反复路径、暂停/跳转、声部控制及 W1 的确定音高/力度/gate 金样。 +6. **端到端测试**:导入→编辑→保存工程→重开→打印/PDF→播放;兼容导出另走 Web→JPW7→Web 检查,不能互相替代。 + +## 11. 与研究证据的对应关系 + +| 规格主题 | 主要证据 | +|---|---| +| 区块和对象 | `docs/JPW7_实现原理与JPW-ABC语法调研.md`、`research/jpw7/evidence-matrix.md` | +| 排版、占位、分页 | `research/jpw7/help-txt/JPW图文教程-怎样排版.txt` | +| 字体角色 | `research/jpw7/fonts-schema.txt`、`jpw7_font_roles_and_rendering` 项目记忆 | +| 多声部和临时声部 | `research/jpw7/help-txt/JPW图文教程-怎样打合唱谱.txt`、`JPW图文教程-怎样使用临时声部.txt` | +| 音符、歌词和反复 | `research/jpw7/help-txt/JPW图文教程-怎样打歌谱.txt` | +| 播放和动态谱 | `research/jpw7/help-txt/JPW图文教程-播放、动态谱.txt` | +| 页面、打印和实机差分 | `research/jpw7/final-audit.md`、`research/jpw7/print-export-experiment.md` | diff --git a/docs/JPW7在线简谱编辑器-开发步骤计划.md b/docs/JPW7在线简谱编辑器-开发步骤计划.md new file mode 100644 index 0000000..36b69fd --- /dev/null +++ b/docs/JPW7在线简谱编辑器-开发步骤计划.md @@ -0,0 +1,306 @@ +# JPW7 在线简谱编辑器开发步骤计划 + +> 版本:v1.1 +> +> 状态:开发执行计划;当前仓库为研究资料与产品文档,尚无 Web 编辑器实现。下列阶段均为待开发,不代表已通过验收。 +> +> 前提:上一阶段调研已收束。AW/Gap、JPW-ABC、附件与锚点、`.Options`、字体、播放和打印按已归档的 Web 决定实现,不重新设置“完成全部逆向研究”的前置门槛。 + +## 1. 依据与交付范围 + +### 1.1 使用哪些文档 + +| 文档 | 用途 | +|---|---| +| [产品需求文档](JPW7在线简谱编辑器-产品需求文档.md) | PRD 编号、用户流程和 P0/P1/P2 范围 | +| [功能规格与技术方案](JPW7在线简谱编辑器-功能规格与技术方案.md) | 模块责任、数据模型、布局和播放契约 | +| [兼容性矩阵](JPW7在线简谱编辑器-兼容性矩阵.md) | 每项功能的显示、编辑、保存、播放与导出能力 | +| [验收标准](JPW7在线简谱编辑器-验收标准.md) | ACC 用例、量化目标、阶段门槛和发布证据 | +| [研究收束记录](../research/jpw7/remaining-gap-closure.md) | 已知事实、未验证内部行为、Web 处理决定 | + +本计划把现有范围排成可执行顺序,不用“先做 MVP”替代最终的 P0+P1 交付,也不承诺 JPW7 私有实现的源码或逐像素等价。 + +### 1.2 两个交付里程碑 + +| 里程碑 | 范围 | 完成定义 | +|---|---|---| +| **R0:首期可用版** | P0/C0;核心编辑、固定纸张、基础播放、工程保存与兼容输出 | 完成 M0–M5;跑通导入→编辑→排版→播放→保存工程→重开→打印;原格式核心导出集通过 JPW7 回读;至少 8 个同时播放声部;C1 未实现部分保留到工程并提示,无法可靠输出时阻止对应输出 | +| **R1:目标版** | P0+P1、C0+C1;手动排版、复杂歌词/附件、多声部、临时声部、常见反复 | 完成 M6–M9;目标版用例全部通过;不能把尚未实现的 C1 仅登记为限制后就宣布目标版完成 | +| **后续扩展** | P2/C2:MP3 同步、动态谱视频、MIDI 导入/导出增强、录谱、外部 SF2、AI 歌声等 | 不阻塞 R0/R1;单独安排实现与验收,不提前搭建这些功能的基础设施 | + +**本次评审修复已获用户确认并同步基线:** + +- 采用独立 `.jianpu.json` 完整工程;`.jpwabc` 是兼容导出。PRD-F-008、技术方案 §4.3~4.5 与验收已同步;工程保存不受私有字段映射限制。 +- 谱内变速/换拍/转调统一纳入 R1/P1,新增 PRD-A-009;原 PRD-A-008 保留连续动态力度与媒体扩展 P2,不再包含中途变速。不是未经确认的单份计划升级。 +- PRD-V-007 明确“首期至少 8 个同时播放声部”,因此不能因多声部编辑属 P1 而把播放容量推迟到 R1。 +- 装饰/力度按技术方案 §7.4 的 W1 清单验收:倚音、重音、单波音、跳音、静态力度可播放;滑音、震音、复式波音、渐强渐弱等仅视觉并提示,其演奏能力 P2。不再用未枚举的“部分播放”作为通过标准。 +- 研究中列为 U 的私有字段可采用保留、诊断、阻止不安全兼容输出的既定策略,不阻止完整工程保存,也不是额外的研发前置课题。 + +## 2. 实现约束 + +### 2.1 沿用既定主链路 + +```text +文件字节 / 新建文档 + → FileCodec + Lexer/Parser + → 保留源片段的 AST + → ScoreModel + 编辑命令/撤销重做 + ├─ ProjectCodec ↔ .jianpu.json(完整工程保存/重开) + ├─ LayoutEngine → LayoutSnapshot → SVGRenderer → 屏幕/SVG/PNG/打印 + ├─ PlaybackCompiler → PlaybackTimeline → Web Audio + └─ 兼容预检 → .jpwabc → Web 再导入 + JPW7 原程序回读 +``` + +- TypeScript 承担解析、模型、布局和播放逻辑;UI 框架不能成为领域模型的依赖。 +- SVG 为正式谱面;Canvas 只用于测量/PNG 等必要环节,不用截图代替矢量排版。 +- 页面尺寸使用稳定物理单位;A4 竖版为宽 210 mm、高 297 mm。若单位为 `mm×100`,viewBox 为 `0 0 21000 29700`。 +- 语义 ID、源位置、布局坐标各司其职;禁止数组下标充当永久锚点、禁止从 SVG 坐标推算音乐时值。 +- 时值使用精确分数或等价整数刻度;转换到音频秒数在时间线阶段完成。 +- 未知内容保存原文、顺序和上下文;能打开不等于能安全编辑或播放。 +- 应用设置与文档设置分离;用户手动布局与文档一起保存在工程,派生 SVG/布局缓存不保存;生产构建默认不含提取的 JPW7 私有字体。 +- 工程格式、原格式写入支持集和外部回验以[技术方案 §4.3~4.5](JPW7在线简谱编辑器-功能规格与技术方案.md#43-工程保存契约)为准;无法写回 JPW7 不等于禁止 Web 编辑或工程保存。 +- 优先本地处理、静态部署与原生 Web API;不为当前需求添加账号、服务端、协作、插件平台或云存储。 +- Worker、复杂缓存和增量布局优化只在测量不达标时引入;但 ≤2 秒/≤100 ms 的产品性能门槛不能省略。 + +### 2.2 五类调研成果如何落地 + +| 已收束主题 | 开发动作 | 主要验收 | +|---|---|---| +| AW/Gap、分页和打印 | 实现可配置时值宽度、占位、Gap、行宽拟合和锁定覆盖;保留原字段;屏幕和打印消费同一布局快照 | ACC-L-016~020、ACC-X-009~011 | +| JPW-ABC 控制码与错误语义 | 将观测库存分类为可解释/仅视觉/原文保留;保留嵌套结构、源位置和未知尾部;提供明确恢复边界 | ACC-F-011~016 | +| 附件、跨行跨页、隐藏符号、临时声部 | 稳定锚点、范围端点、跨行分段显示;删除后孤立对象保留并诊断;可见性与时值分离 | ACC-O-001~006、ACC-V-001~007 | +| `.Options` 与手动状态 | 工程完整保存文档设置/布局;原格式只写已验证映射,保留未知原文;应用偏好不污染文档;兼容导出损失布局须确认,语义不安全则阻止 | ACC-F-017~022、ACC-X-012~013 | +| 字体与播放 | 字体角色、合法替代、私有码位保留;播放独立编译,装饰/力度按 W1 金样;随包 MIDI 只作限定对照 | ACC-G-001~004、ACC-A-015~023 | + +## 3. 依赖与阶段总览 + +```text +M0 基线/工程 + → M1 文件与语法 + → M2 语义模型/命令 + → M3 固定纸张/SVG + → M4 基础编辑 UI + → M5 基础播放/导出/首期验收 = R0 + → M6 歌词/装饰/复杂附件 + → M7 手动排版/多声部/临时声部 + → M8 反复/扩展播放 + → M9 全量回归/兼容/发布 = R1 +``` + +这是交付顺序,不要求等 UI 完成后才开始写导出或播放测试:M2 后即可测试纯时间线,M3 后即可验证 PDF。每阶段必须留下可运行检查,不能把全部测试推到 M9。 + +### M0:冻结执行基线与最小工程 + +**输入:**现有 PRD、兼容性矩阵、验收标准、33 对原始/解码样例与研究变体。 + +**步骤:** + +1. 建立版本控制和最小 TypeScript Web 工程,确定唯一构建、测试与本地运行入口;不预建多包/服务端架构。 +2. 为 R0/R1 建立需求清单,逐项记录 PRD、能力维度、阶段和 ACC 编号。 +3. 建立只读样例清单:路径、SHA-256、预期诊断、适用里程碑;派生变体放测试目录,不改研究原件。 +4. 将 33 个样例、8 类已观察区块、214 个观测花括号头登记为覆盖基线;不得把库存数量当作完整语言规范。 +5. 选择允许分发的字体/基础音色并记录来源、许可和版本;固定主验收浏览器、机器和字体组合。 +6. 建立最小测试入口、报告目录和构建检查;记录文件大小/对象数量等输入上限,供错误测试复用。 +7. 落实工程 v1 字段和原格式写入清单,登记 JPW7 v7.30 回验环境/版本哈希;核心集与扩展项分开,不因实现难度删掉必选项。 + +**交付:**可启动空应用、锁定依赖、测试入口、fixture manifest、R0/R1 用例清单、资源许可清单、工程 schema 与原格式写入清单。 + +**出门条件:**可从干净环境构建并执行至少一个检查;33 对样例映射完整且原件哈希未变;所有 P0/P1 需求已落到阶段,没有“研究完成后再定”的悬空项。 + +### M1:文件编解码、解析与无损保留 + +**依赖:**M0。 + +**步骤:** + +1. 检测 UTF-16 编码/BOM,建立区块和源范围,保留原始文件与未修改片段。 +2. 实现核心音符、休止、时值、标题和已支持控制码;扫描嵌套花括号、参数、歌词头和附件头。 +3. 为未知 token/区块建立保留节点,不用“只取数字与小节线”的过滤器吞掉内容。 +4. 定义损坏编码、未闭合结构、非法值、未知版本/字段的错误级别和恢复边界;提供源位置和兼容性报告。 +5. 实现原格式稳定序列化:已支持语义等价、未知载荷及相对顺序保留、重复输出收敛;建立兼容预检,不能把自我 round-trip 当原程序回验。 +6. 分离 ApplicationOptions/DocumentOptions;建立已支持字段映射,复合参数和未知尾部按原文保存;工程中的设置不强迫映射成 JPW 字段。 + +**交付:**FileCodec、Parser、保留源片段的 AST、诊断报告、序列化与字段映射测试。 + +**出门条件:**33 个原始样例全部完成解析/保留及 round-trip 分类检查;未实现不等于失败,但丢内容、误解释、无诊断、无法解释的漂移均失败。ACC-F-001~008、011~019 在本阶段适用的底层检查通过。 + +### M2:语义模型、稳定锚点与编辑命令 + +**依赖:**M1。 + +**步骤:** + +1. 建立文档、声部、小节、音符/休止、歌词、附件、页面对象和字体角色模型。 +2. 分离原文、规范语义和布局状态;所有可引用对象使用稳定 ID。 +3. 实现插入/删除/替换、标题和基础音乐属性修改、撤销/重做及脏状态。 +4. 对锚点删除、端点失效和未知关联实施保留+诊断;不猜测 JPW7 的迁移规则。 +5. 校验小节时值、对象引用和保存能力;拒绝非法编辑时整个命令不得部分生效。 +6. 实现 ProjectCodec 和工程版本/结构检查,保存当前模型、原始内容、稳定 ID、文档状态与孤立附件;恢复时不重解析原文件覆盖编辑结果,也不存派生缓存/撤销栈。 + +**交付:**ScoreModel、CommandStore、ProjectCodec、模型等价比较、命令/锚点及工程恢复检查。 + +**出门条件:**插入→修改→删除→撤销→重做→保存工程→重开结果等价;稳定 ID 保持;ACC-E-001~003、ACC-F-009/021~022、ACC-O-001~002 的模型测试通过,C1 布局状态随 M6/M7 扩展。 + +### M3:固定纸张布局、字体测量与 SVG + +**依赖:**M2。 + +**步骤:** + +1. 实现 A4、边距、版心和标题区域,等待字体加载完成后测量。 +2. 顺序实现测量→四向占位→自动间距→按小节折行→自动行距/段落间距→分页→LayoutSnapshot。 +3. 实现预设、左对齐/两端对齐切换、可调 AW/Gap 映射;保留未解释的原字段,不硬编码成“官方公式”。 +4. 建立乐谱层、页面对象层和屏幕辅助层;用 SVG 几何绘制弧线/线条等非文字对象。 +5. 处理超宽单小节、字体替换、跨页和页面溢出;不靠隐藏裁剪掩盖错误。 +6. 输出首个 SVG/PDF 探针,用同一快照验证物理尺寸、分页和字体实际效果。 + +**交付:**LayoutEngine、LayoutSnapshot、SVGRenderer、测量基线、布局差异报告。 + +**出门条件:**康定情歌的 16 小节结构正确;1 页/6 行参考结果有逐项对照;页数/行数差异不能自动接受为新金样。ACC-L-001~008、013~018、ACC-G-001~003 和 ACC-X-009 的适用检查通过。 + +### M4:基础编辑界面与文档工作流 + +**依赖:**M3。 + +**步骤:** + +1. 实现导入 `.jpwabc`、打开/保存 `.jianpu.json`、新建、文件名/脏状态、解析报告、撤销/重做与重载保护;菜单明确区分“保存工程”和“兼容导出”。 +2. 通过 LayoutSnapshot/SVG ID 完成点击命中、光标、选区、键盘导航;缩放不改语义坐标。 +3. 提供核心音符/休止、音高/八度/变音、时值/附点、小节线、标题/作者/调号/拍号/初始速度/表情文字编辑。 +4. 支持基础单段歌词输入、修改、删除和锚定;为后续复杂歌词保留模型关系而不是页面字符串。 +5. 提供兼容性面板与源片段定位、错误恢复、键盘等效操作和无障碍名称。 + +**交付:**可实际编辑的单声部应用、核心交互端到端检查、无障碍检查记录。 + +**出门条件:**不靠手改源文件即可完成新建→录入→试听入口→保存工程→重开;核心命令可用键盘完成;ACC-F-008~010、020~022、ACC-E-001~004/007、ACC-T-001、ACC-Q-001~006 通过适用检查。 + +### M5:基础播放、输出闭环与 R0 验收 + +**依赖:**M2 的语义契约、M3 的布局、M4 的 UI。 + +**步骤:** + +1. 编译核心音高、分数时值、休止、调号、拍号与初始速度;至少支持 8 个声部同时调度。 +2. 实现用户手势启动、播放/暂停/继续/停止、从光标播放、进度跳转、基础音色选择/切换、静音/独奏/音量。 +3. 时间线关联语义 ID 和页面指示条;暂停/跳转取消未来事件,停止释放活动发声节点。 +4. 打通 `.jpwabc` 兼容导出、逐页 SVG、明确 DPI 的 PNG、浏览器 PDF/打印;关闭所有辅助层;执行核心写入集的 Web→JPW7→Web 回验并归档。 +5. 未知对象保留到完整工程;对不可靠播放/兼容导出/图形输出分别警告或阻止;丢失 Web 版式的兼容输出须确认,成功导出不清除工程脏状态。 +6. 执行 R0 全量数据安全、浏览器、无障碍、性能和端到端验收;不能留到目标版才补这些基础质量。 + +**交付:**R0 候选构建、PlaybackTimeline、音频控制器、导出/打印功能、R0 证据包和兼容性报告。 + +**出门条件:**[验收标准 §3](JPW7在线简谱编辑器-验收标准.md#3-通过规则) 的 R0 门槛全部满足;所有未完成 C1 明确标注,不宣称目标版完成。 + +### M6:复杂歌词、谱面装饰和附件 + +**依赖:**M5;复用 M2/M3,不另建“高级谱面”渲染器。 + +**步骤:** + +1. 完成多段、段号/前缀、一音多字、横线、中英文混合、移动/锁定和垂直间距。 +2. 完成双细线/虚小节线、反复记号、标准/特殊弧线、延音/连音、三连音/多连音、倚音及已定义装饰/力度/换气对象;器乐指法和锣鼓经按矩阵的已知显示范围处理,显示和播放语义分别标注。 +3. 实现和弦名称、柱式和弦、前奏/间奏/尾奏与括号伴奏的语义区别。 +4. 区分普通文字、特殊文字、页面文本框;支持视觉调号/拍号但不误改实际音乐语义。 +5. 实现范围附件跨行/跨页分段和续接;处理删除端点、隐藏符号及保留的未知锚点。 +6. 私有字形保存码位/字体角色/锚点;未知字形不猜音高,不分发未获授权字体。 + +**交付:**复杂歌词和附件编辑器、跨边界布局、字形兼容报告、相应模型/布局/保存检查。 + +**出门条件:**ACC-E-004~012、ACC-T-001~007、ACC-O-001~006、ACC-L-007/012/020、ACC-G-001~004 中,本阶段的显示/编辑/保存/单声部跨页检查通过。依赖多声部/锁定的子项在 M7,复杂播放子项在 M8 完成;这些未执行子项仍登记在 R1 清单,不能以本阶段视觉通过替代。 + +### M7:手动排版、多声部与临时声部 + +**依赖:**M6。 + +**步骤:** + +1. 完成全局/局部间距、锁定/解锁、行距/段落间距、移入移出小节、强制换行;重排尊重手动约束。 +2. 完成声部组建立、修改、拆分、合并、还原;校验对应小节数量和时值。 +3. 实现节拍对齐、声部名称、连谱号、贯通小节线、反复圆点和出头线。 +4. 实现临时声部独立内容、歌词、字体、缩放、自动/手动对齐及解除关联。 +5. 对跨行、空行、声部一变二、隐藏主声部和不匹配声部明确诊断;隐藏不等于删除或静音。 +6. 将锁定、手动分行、歌词位置和临时声部对齐等用户状态写入工程,重开验证恢复;`.jpwabc` 不能携带的版式覆盖按清单确认后导出副本,语义/关联不能可靠表示时仅阻止兼容导出,不能阻止工程保存。 + +**交付:**手动排版、多声部编辑、临时声部、工程持久化与兼容预检、布局回归。 + +**出门条件:**ACC-V-001~007、ACC-L-009~012/019~020、ACC-F-017~022、ACC-X-013 的本阶段编辑/布局/工程保存检查通过;所有承诺的手动状态均须恢复,不允许以“原格式不支持”豁免。临时声部特殊播放模式在 M8 完成。 + +### M8:常见反复与扩展播放语义 + +**依赖:**M6/M7 的模型及 M5 播放器。 + +**步骤:** + +1. 编译常见反复、房子、DC/DS 等确定播放路径;设置路径展开上限并检测循环/冲突。 +2. 按技术方案 §7.4 W1 实现标准弧线/延音、倚音、单波音、重音、跳音、静态力度及声明的组合,另实现柱式和弦/伴奏;仅视觉对象不得改变音符事件,未知组合明确拒绝不可靠播放。 +3. 实现 PRD-A-009 的谱内变速/换拍/转调;从光标或反复入口开始时恢复应生效的控制状态,包括声部静态力度。 +4. 按已定义模式处理临时声部和主声部播放;不能确认的 `PlayMode/cpo` 原文保留并在播放前提示。 +5. 将随包 4 个 MIDI 和已有音高序列匹配用作限定诊断;不得自动视为当前源谱逐事件金样。 +6. 对复杂自定义顺序、MP3/动态谱/自动节奏等 P2 内容给出能力边界,不生成看似正确的近似结果。 + +**交付:**RepeatResolver、控制事件时间线、复杂示例播放测试、范围明确的 MIDI 对照报告。 + +**出门条件:**ACC-A-006~011、015~023 通过对应 R1 断言;反复控制状态及 W1 的音高、起点、分数时值、velocity/gate 可自动检查,另检查实际发声,不能只靠听感或只有效果标签的日志。 + +### M9:目标版回归、资源审计与发布 + +**依赖:**M0–M8 完成。 + +**步骤:** + +1. 全量执行 33 样例、研究变体和新增最小测试;追溯所有 P0/P1 及混合 P0/P1 需求;覆盖工程恢复和所有已承诺的 JPW7 写入项的外部回验。 +2. 在 Chromium、Edge、Firefox 最新两个大版本验收;Safari/移动只按既定目标独立列报告。 +3. 完成 10 页性能、连续编辑、10 分钟播放、字体失败和超限输入测试。 +4. 实测单页/多页 PDF、SVG/PNG 独立打开和至少一个实体打印路径;无设备时该项标“未执行”,不能写已通过。 +5. 检查发行包没有研究字体/私有样例/原始日志等未经授权资源;导入内容不能执行脚本或触发未确认外部加载。 +6. 生成版本、资源许可、兼容性声明、已知限制和验收报告,保留上一可用发行包及回退说明。 + +**交付:**R1 构建、完整证据包、逐项需求结果、部署/回退说明。 + +**出门条件:**R1 必选用例全部通过、阻断与严重缺陷为 0、没有以“有条件通过”掩盖未完成 C1 的条目。满足后才称“P0+P1 目标版完成”。 + +## 4. 需求到阶段、验收的追踪 + +`~` 表示同前缀连续编号;具体操作和预期见验收文档。实施时每条 PRD 仍须各自记录结果,不能只勾选整个分组。 + +| 需求 | 主要阶段 | 验收编号 | +|---|---|---| +| PRD-F-001~007 | M1/M2/M4/M5 | ACC-F-001~020、ACC-L-001~002、ACC-X-001~013 | +| PRD-F-008 | M2/M4/M6/M7 | ACC-F-021~022;ACC-L-009~011/019;ACC-X-013 | +| PRD-E-001~004 | M2/M4/M6 | ACC-E-001~004、007、011;ACC-F-009 | +| PRD-E-005~008 | M6/M8 | ACC-E-005~006、008~012;ACC-O-001~006;ACC-A-009/017/021~023 | +| PRD-T-001~006 | M4/M6 | ACC-T-001~007;ACC-L-006/012 | +| PRD-V-001~006 | M7 | ACC-V-001~006;ACC-O-003~006;ACC-L-020 | +| PRD-V-007 | M5/M7 | ACC-V-007;ACC-A-015 | +| PRD-L-001~006/010 | M3/M6 | ACC-L-001~008、013~018、020;ACC-Q-002 | +| PRD-L-007~008 | M7 | ACC-L-009~011、019 | +| PRD-L-009 | M3/M6 | ACC-L-005/015;ACC-G-001~004 | +| PRD-A-001~003/006~007 | M5 | ACC-A-001~008、012~016 | +| PRD-A-004~005 | M8 | ACC-A-009~011、017~023 | +| PRD-A-008 | 后续扩展 | P2 媒体/连续动态力度不作为 R1 演奏承诺;R1 视觉降级按 ACC-A-023 检查 | +| PRD-A-009 | M8 | ACC-A-006/018/022;ACC-E-007;ACC-T-007 | +| PRD-U-001~004 | M4/M5/M9 | ACC-Q-004~006;ACC-L-013;ACC-X-003/006 | +| 全局质量与安全 | 各阶段/M9 | ACC-Q-001~009;验收文档 §10 性能和 §11 证据包 | + +## 5. 每阶段统一完成定义 + +- [ ] 对应功能实际可用;需要 UI 的能力不能只交付底层函数。 +- [ ] 需求、支持维度、ACC 用例和证据路径一一对应。 +- [ ] 非平凡解析、编辑、布局、播放逻辑至少有一个能发现错误的可运行检查。 +- [ ] 类型检查、构建、当前阶段检查和已有回归通过。 +- [ ] 源文件、研究样例和已通过金样没有被测试过程覆盖。 +- [ ] 新增未知项、降级项和风险均可定位;未完成用例仍为“未执行/失败”,不自动转为通过。 +- [ ] 工程完整恢复、原格式支持集的外部回验、纯图形导出、播放分别核对,不能互相替代。 +- [ ] 更新兼容性报告;调整产品范围必须同步计划、PRD、矩阵与验收,不用改金样掩盖回归。 + +## 6. 最先实施的一批任务 + +按以下顺序进入 M0/M1,不先画一套脱离真实数据的完整界面: + +1. 建最小工程和测试入口,登记字体/音色许可及测试环境。 +2. 为 33 对样例生成只读 manifest;复用现有 `research/jpw7/repro/make_variants.py` 生成测试副本。 +3. 实现 UTF-16 解码、区块切分、源范围与未知内容保留。 +4. 写入“未编辑原格式 round-trip、未知块保留、损坏文件诊断”三类最小自动检查;登记核心原格式回验 fixture,不用自我往返替代原程序验证。 +5. 解析康定情歌核心语义,生成第一份对象与诊断报告;M2 随模型接入工程保存/重开检查,再进入排版。 + +本计划按可验收成果推进,不在尚无实现和性能实测时编造精确人日。M0 完成后可依据实际人员和迭代速度补排期,但不得因此改变 R0/R1 的验收范围。 diff --git a/docs/JPW7在线简谱编辑器-验收标准.md b/docs/JPW7在线简谱编辑器-验收标准.md new file mode 100644 index 0000000..4270e4c --- /dev/null +++ b/docs/JPW7在线简谱编辑器-验收标准.md @@ -0,0 +1,477 @@ +# JPW7 在线简谱编辑器验收标准 + +> 版本:v1.2 +> +> 当前状态:调研已收束,进入开发执行;Web 编辑器尚未实现,下列条目全部为待执行标准,不是已通过结果。 +> +> 配套:[开发步骤计划](JPW7在线简谱编辑器-开发步骤计划.md)。本文区分 R0 首期可用版与 R1(P0+P1)目标版,不把“研究完成”与“功能通过验收”混为一谈。 + +## 1. 验收原则 + +1. 先验证数据和语义,再验证页面像素;不能用截图通过数据丢失问题。 +2. 参考软件事实、实现推断和 Web 产品目标分别记录。 +3. 原始安装文件、原始样例不得修改;所有变体使用副本并记录 SHA-256。 +4. 每个失败项必须记录输入文件、配置、浏览器、日志、截图和复现步骤。 +5. 未实现或未验证对象必须可见提示;静默丢失直接阻断发布。 +6. 视觉验收以固定页面 SVG/PDF 为准,屏幕缩放不改变页面几何。 +7. 每个用例分别记录显示、编辑、保存、播放和导出中适用的能力;原文可保留不代表可正确播放或打印。 +8. 先确定预期再运行测试;不得直接用本次输出覆盖金样,使失败变成通过。数值目标均为 Web 产品目标,不是已测得的 JPW7 内部参数。 +9. 用户已确认独立 `.jianpu.json` 工程。本文未明确写 `.jpwabc` 的“保存后重开/再导入”指工程恢复;导入研究样例仍指原始 JPW 文件。工程保存、JPW7 兼容导出、图形输出分开验收,不能用阻止兼容导出代替工程保存。 +10. 工程/写入支持集按[技术方案 §4.3~4.5](JPW7在线简谱编辑器-功能规格与技术方案.md#43-工程保存契约);装饰播放按该文档 §7.4 W1。谱内变速/换拍/转调已由用户确认纳入 R1(PRD-A-009),不是待定范围。 + +## 2. 验收环境 + +### 2.1 主环境 + +- 主环境:Windows 桌面 Chromium/Edge 稳定版,记录实际完整版本号、操作系统、CPU、内存、显示缩放和设备像素比。 +- 发布环境:Chromium、Edge、Firefox 最新两个大版本;测试启动日固定版本清单,不能仅写“最新版”。Safari/移动端按既定兼容性目标单列,不用它们替代必测环境。 +- A4 竖版,打印缩放 100%,关闭浏览器默认页眉页脚;应用边距与打印对话框边距分别记录,防止二次留白。 +- 字体分别测试:固定且许可允许的字体加载成功,以及缺失/网络失败后的替代路径;记录实际字体、哈希和加载状态。 +- 浏览器音频允许与拒绝用户手势启动两种状态;记录音频采样率、输出设备和后台标签页状态。 +- 保存目录可写、下载被拒绝、存储配额不足等情况分别测试;原件始终只读。 +- 性能基线使用同一机器、生产构建及固定样例;不把有调试探针的运行与正常运行混合统计。 +- 原格式写出验收固定 JPW7 v7.30 的实际版本/二进制哈希、字体与操作环境;必须实际打开 Web 导出文件检查语义并另存副本。缺原程序环境时 ACC-X-012 为未执行,不用 Web 自我往返替代。 + +### 2.2 基线样例 + +| 样例 | 用途 | +|---|---| +| `research/jpw7/decoded/JP-Word练习曲-歌曲-康定情歌.jpwabc.txt` | 单声部、歌词、基础排版和播放主样例 | +| `research/jpw7/decoded/JP-Word介绍-创作版简介.jpwabc.txt` | 标题、文字、复杂对象和创作版能力 | +| `research/jpw7/decoded/JP-Word介绍-功能简介.jpwabc.txt` | 普通介绍页和页面对象 | +| `research/jpw7/demo/JP-Word介绍-功能简介.jpwabc` | UTF-16 原始导入样例 | +| `research/jpw7/` 下已归档样例 | 多声部、器乐、戏曲、附件和变体回归 | + +测试前复制样例到临时目录,保存原文件 SHA-256;不得直接覆盖 `research/jpw7` 中的原始材料。 + +### 2.3 必备测试集 + +以下是 M0 起逐步建立的测试集,不表示当前已有 Web 测试实现。 + +| 测试集 | 输入来源 | 必查内容 | +|---|---|---| +| 原始语料 | `research/jpw7/demo/` 的 33 个 `.jpwabc`,对应 `decoded/` 的 33 个解码文本 | 原件哈希、解析/保留分类、已支持语义、未知载荷、保存再导入;逐个记录是否可预览/播放/导出 | +| 语法库存 | `syntax-inventory.md`、`control-code-observations.md` | 8 类观测区块、214 个观测花括号头全部有分类记录;这不是“完整语言包含 214 条指令”的声明 | +| 间距变体 | `repro/aw-spacing/` 及 `formula-experiment.md` | 短式/完整式 AW、Gap、预设、行宽变化、确定性和未知尾部保留 | +| 锚点与删除 | `repro/cross-boundary/`、`logs/native-relocation/`;删除 `.Words`/`.Attachments` 的副本 | 删除前后对象关系、跨小节端点、孤立对象、序列化与撤销 | +| 设置写回 | `options-persistence-findings.md`、`options-writeback-verification.md` | 应用级与文档级隔离,已支持字段修改,未知字段/复合值保留 | +| 字体与字形 | `private-glyph-semantics.md` 的 35 个实际使用私有码位 | 码位/角色/锚点保留、缺字提示、替代、生产包授权检查;研究字体不作为必须分发的测试依赖 | +| 播放诊断 | 4 个随包 MIDI 的 `midi-event-inventory.json` 与源谱对照报告 | 按证据限定的音高/序列检查;不可直接当作时值、音色和全部轨道的金样 | +| 新建最小案例 | Web 测试目录中的人工可核算输入 | 分数时值、8 声部、连音/三连音、反复/房子/DC/DS、变速、隐藏符号、空行/错拍、超宽小节、跨页附件 | +| 工程恢复 | 新建及旧谱编辑所得 `.jianpu.json` | schemaVersion、稳定 ID、未知内容、文档设置、所有手动覆盖、孤立状态、播放规则版本;关闭应用后完整恢复 | +| 原格式独立回验 | 技术方案 §4.5 的核心/已承诺扩展写入清单及人工确定期望 | 新建、标题修改、音符/时值/歌词编辑、未知原文保留;Web→JPW7 打开/另存→Web 比较,不能只测试原件透传 | +| W1 播放金样 | 本文 §7.2 人工可核算事件与对照音频 | 每种倚音/单波音/重音/跳音/静态力度、支持组合、仅视觉不变、未知组合拒绝和工程恢复 | +| 故障与安全 | 测试目录生成,不改研究原件 | 损坏编码、未闭合/嵌套 token、未知字段、错误锚点、外部 URL/脚本载荷、超限输入和保存失败 | +| 性能案例 | 固定字体/配置下约 10 页的确定性文档 | 输入哈希、页数/对象数、生成种子、首次布局、连续编辑和长时播放 | + +路径未写前缀的研究文件均相对 `research/jpw7/`。每个 fixture 至少登记:ID、源路径、哈希、生成方法(若有)、支持类别、预期诊断、适用里程碑和关联 ACC。 + +**33 样例覆盖率的含义:**全部运行并产生分类报告,不要求 R0 将所有 P1/P2 对象完整实现;但不能跳过难样例后声称全量通过。R1 则必须实现其中约定的 C1 能力,明确的 V/C2/U 仍按既定策略处理。 + +## 3. 通过规则 + +### 3.1 单项结果 + +- **通过**:实际结果满足预期,适用能力均检查,证据完整。 +- **有条件通过**:仅用于当前里程碑范围外或事先约定的 V/C2 降级;必须说明差异、影响和批准记录,不能冲抵必选功能的失败。 +- **失败**:结果错误、不可重复、无提示、超出允许差异或缺少必需证据。 +- **未执行**:缺实现、缺环境、缺打印设备或尚未运行;不计为通过。 +- **不适用**:该里程碑明确不包含的能力,必须记录理由;不能把必选 C1 在 R1 标成不适用。 +- **阻断缺陷**:静默丢失、未提示裁切、已支持播放时间线错误、核心文件打不开、保存后不能安全再导入、注入执行或未授权资源分发。 + +### 3.2 发布门槛 + +| 门槛 | R0 首期可用版 | R1 目标版 | +|---|---|---| +| 必选范围 | P0/C0、工程保存、原格式核心写入集原程序回验,以及 PRD-V-007 的至少 8 声部播放 | P0+P1 / C0+C1,包括谱内控制事件与 W1;所有 Web 手动状态须能保存工程,但不自动承诺全部可写回 JPW7 | +| 必选用例结果 | 100% 通过,不允许未执行或以条件通过替代 | 100% 通过;不能只登记尚未实现的 C1 就发布为目标版 | +| 未知、V、C2、U | 原文/关联可保留、诊断可见;不可靠操作阻止或按明确边界降级 | 同左;不把已约定的保守 Web 策略重新变成待调研阻塞 | +| 33 样例 | 全部有导入、工程保存/重开、布局、播放、导出状态;被阻止的输出环节记录诊断,不得丢掉工程保存出口 | 同左,且目标版支持能力均有实际执行证据 | +| 阻断/严重缺陷 | 0;严重指核心流程无法完成且无可靠替代路径 | 0 | +| 横向质量 | 必测浏览器、无障碍、性能、安全、字体许可、单/多页输出通过 | 同左,并包含所有新增 C1 场景 | +| 证据 | 本文 §11 的证据包完整 | 同左,另附 P0/P1 逐项完成记录 | + +已有表格的“级别”列中,`阻断` 表示严重性,这些核心用例在 R0/R1 均必测;C0 为 R0 必选,C1 为 R1 必选。混合项按以下拆分: + +- ACC-T-001:R0 基础单段歌词与休止跳过;R1 补齐弧线、伴奏及复杂歌词对位。 +- ACC-L-008:R0 自动两端对齐;R1 叠加手动锁定约束。 +- ACC-E-004:R0 普通/终止小节线和其他记号的保留/提示;R1 完成双细线/虚小节线、反复编辑与播放路径。 +- ACC-A-007~008、015:R0 至少 8 声部调度与基础静音/独奏/音量;复杂声部组织和临时声部在 R1。 +- ACC-A-006/018:R0 初始速度/拍号/调号;R1 谱内变速/换拍/转调及状态恢复,按已确认 PRD-A-009。 +- ACC-G-004:R0 基础合法字形与未知风格保留;R1 完成约定的字形风格选择和测量。 +- ACC-F-017/ACC-O-001:R0 核心字段及基础歌词锚点安全修改;R1 扩展到手动布局、复杂附件和临时声部。 +- ACC-A-019:R0 对尚不支持的反复/模式明确拒绝不可靠播放;R1 还必须测试已实现路径的循环检测和展开上限。 +- ACC-F-021:R0 完整保存已有核心模型/未知原文;R1 增加所有已承诺手动覆盖、临时声部和复杂附件。工程文件不能以只保存源 JPW 文本代替当前模型。 +- ACC-X-012:R0 核心写入集必测;R1 再覆盖所有额外声明为兼容写出的能力。没有外部证据的扩展项不得标为支持,但不能因此缩减 R1 的工程/编辑能力。 +- ACC-X-013:R0 覆盖输出边界/确认/脏状态,R1 增加实际手动布局差异;ACC-A-021~023 为 R1 必选,R0 对这些输入仍须保留和提示。 + +R0 的 C1 原文保留及不安全操作提示必须测试,不能因编辑能力尚未实现而跳过数据安全。已承诺支持的编辑/保存不能仅靠一律阻止操作通过验收;保留/阻止策略只用于范围外或证据不足的对象。 + +## 4. 文件与解析验收 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-F-001 | 导入 UTF-16 `.jpwabc` 原始副本 | 正确识别编码;页面可显示;无乱码 | 阻断 | +| ACC-F-002 | 导入含 `.Options`、`.Fonts`、`.Title`、`.Voice`、`.Words`、`.Attachments`、`.Page` 的样例 | 解析报告列出区块和对象计数 | 阻断 | +| ACC-F-003 | 导入含未知控制码/未知区块的样例 | 显示警告,源片段可定位,保存不静默删除 | 阻断 | +| ACC-F-004 | 删除 `.Words` 的副本并导入 | 文件仍可打开;歌词对象数减少;音符/小节结构不被破坏 | 阻断 | +| ACC-F-005 | 删除 `.Attachments` 的副本并导入 | 文件仍可打开;附属对象减少;核心旋律结构不被破坏 | 阻断 | +| ACC-F-006 | 打开空区块或缺少可选区块的文件 | 采用默认值或显示明确错误,不崩溃 | C0 | +| ACC-F-007 | 导入 JPW 后不编辑,保存工程并重开 | 已支持语义、未知块及原始来源恢复;工程不用重新解析来源覆盖当前状态 | 阻断 | +| ACC-F-008 | 修改标题后保存再导入 | 新标题正确保留;作者和其他未修改对象保持不变 | C0 | +| ACC-F-009 | 修改音符后撤销/重做 | 模型、预览和保存结果与操作历史一致 | C0 | +| ACC-F-010 | 模拟保存失败或下载失败 | 原有效文件不被覆盖,用户收到可理解错误 | 阻断 | +| ACC-F-011 | 对 33 样例逐项导入并核对语法库存 | 全部有已解析/仅视觉/保留/错误分类;214 个观测头均有处理归属,未知载荷不减少 | C0 | +| ACC-F-012 | 导入嵌套花括号、复合 `{C:...}`、`cpo`、扩展歌词头及未知尾部 | 不截断嵌套结构;未解释参数原文与相对顺序保留,不误当音符 | 阻断 | +| ACC-F-013 | 导入错误 BOM/截断 UTF-16、未闭合 token、非法已知参数、未知版本 | 区分损坏与不支持;已知非法值不默默默认化;标出源范围;不能恢复时阻止相关操作,应用仍可用 | 阻断 | +| ACC-F-014 | 在兼容支持集内连续导出 `.jpwabc` 并由 Web 再导入 3 次 | 每次规范语义等价;首次规范化输出后字节稳定;未知片段不变;此项不能替代 ACC-X-012 外部回验 | C0 | +| ACC-F-015 | 修改已知内容且未知块邻接或锚定到该内容 | 安全关系保留;不能安全迁移时阻止该编辑或兼容导出并诊断,未决/孤立状态可保存在工程,不无提示迁移 | 阻断 | +| ACC-F-016 | 删除/增加空的可选区块,使用区块顺序变体 | 核心结构不被误改;不支持的顺序可恢复或明确报错,不能静默吞区块 | C0 | +| ACC-F-017 | 修改页面/间距/字体和 Web 覆盖,保存工程并重开;另行尝试兼容导出 | 工程完整恢复,不因缺 JPW 映射禁止 Web 编辑;兼容导出仅写验证字段,版式差异确认或语义不安全阻止按 ACC-X-013 | C0/C1 | +| ACC-F-018 | 修改应用偏好后重载应用及原文档 | 应用偏好按既定存储策略保留;文档字节和 DocumentOptions 不被污染 | C0 | +| ACC-F-019 | 保存含未知 `.Options` 字段、复合值和额外尾部的工程;兼容安全时另行导出 JPW | 工程始终保留原文及 Web 覆盖;兼容输出保留未修改字段,只修改已验证映射,不猜私有含义或把 Web 覆盖伪装成原字段 | 阻断 | +| ACC-F-020 | 新建、编辑、撤销到初始状态、重载或离开页面 | 脏状态与当前内容一致;有未保存修改时提示或恢复草稿;失败不能丢掉最近有效文档 | C0 | +| ACC-F-021 | 保存 `.jianpu.json`,关闭应用再打开;重复规范化保存 3 次 | 当前模型/稳定 ID/未知原文/孤立状态/所有已承诺手动覆盖/W1 版本完整恢复;同环境派生布局一致;不保存撤销栈或派生缓存;首次规范化后字节稳定 | C0/C1 | +| ACC-F-022 | 打开未知工程/播放规则版本、错误字段/类型、截断 JSON、超限工程和非法悬空引用 | 版本/结构校验先于模型替换;明确拒绝且原件/当前工程不变,不假装迁移或降级规则;合法 unknownBlocks 与显式孤立对象不被误拒绝 | 阻断 | + +## 5. 语义编辑验收 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-E-001 | 插入、删除、替换音符 | 只改变目标符号;锚定歌词/附件按规则更新或提示 | C0 | +| ACC-E-002 | 修改音高、八度、变音 | 谱面显示、模型音高和播放音高一致 | 阻断 | +| ACC-E-003 | 修改时值/附点/休止 | 小节时值、间距、歌词位置和播放时长同步更新 | 阻断 | +| ACC-E-004 | 添加、修改、删除普通/终止/双细/虚小节线及反复小节线 | 视觉对象和音乐边界按类型正确;折行不把虚小节线误算成完整小节;保存和撤销一致 | C0/C1 | +| ACC-E-005 | 添加标准弧线 | 弧线显示;歌词对位和播放可识别 | C1 | +| ACC-E-006 | 添加特殊弧线 | 视觉显示正确;不会误当作标准播放弧线 | C1 | +| ACC-E-007 | 添加/修改标题、词曲作者、调号、拍号、初始速度和表情文字 | 页面及再导入正确;基础播放读取音乐控制;纯表情文字不生成音符 | C0 | +| ACC-E-008 | 添加和弦名称 | 显示并可编辑;不会自动产生错误音符 | C1 | +| ACC-E-009 | 添加柱式和弦 | 视觉正确;播放为对应多个音符事件 | C1 | +| ACC-E-010 | 添加倚音/重音/波音/滑音/震音/跳音、力度、换气、器乐指法和锣鼓经样例 | 逐类型核对显示/编辑/工程保存;W1 可播放项还必须通过 ACC-A-021~023;仅视觉明确提示,不能以显示通过替代播放 | C1 | +| ACC-E-011 | 编辑三连音/多连音、延音及组合时值 | 精确时值和分组关系正确;视觉、歌词、保存与时间线一致,不累积浮点误差 | C1 | +| ACC-E-012 | 添加前奏、间奏、尾奏、括号伴奏 | 与主旋律关系保留;歌词跳过规则正确;纯文字不额外发声,音乐内容按定义进入时间线 | C1 | +| ACC-T-001 | 添加一段歌词 | 音节按规则锚定,休止符和弧线不错误占位 | C0/C1 | +| ACC-T-002 | 添加多段歌词、段号和歌词前缀 | 每段独立显示、编辑和导出 | C1 | +| ACC-T-003 | 添加英文/中英混合歌词 | 分词和对齐结果可复核;异常处可手动修正 | C1 | +| ACC-T-004 | 锁定歌词后改变音符间距 | 锁定位置不被自动排版覆盖,或明确提示冲突 | C1 | +| ACC-T-005 | 添加普通文字、特殊文字、文本框 | 三种对象可区分;文本框不改变乐谱流排版 | C1 | +| ACC-T-006 | 编辑一音多字、歌词横线、标点、段落行距并删除/移动歌词 | 音节与音符锚点不串位;撤销/重做和再导入恢复关系及样式 | C1 | +| ACC-T-007 | 添加仅视觉的调号/拍号文字及真实调号/拍号 | 两类对象有明确标识;仅真实音乐控制改变时间线;均可正确保存 | C1 | +| ACC-V-001 | 选中多行执行多声部处理 | 声部组建立;小节数/时值不一致时阻止并说明 | C1 | +| ACC-V-002 | 修改声部名称/连谱号 | 页面显示、保存和再导入一致 | C1 | +| ACC-V-003 | 添加临时声部并自动对齐 | 临时声部跟随主声部间距;可切换手动对齐 | C1 | +| ACC-V-004 | 临时声部带歌词/倚音/和弦/独立字体 | 关联和显示不丢失;布局警告可定位 | C1 | +| ACC-V-005 | 修改、拆分、合并声部组并还原为单声部 | 内容和稳定 ID 不丢失;名称、连谱号、贯通小节线/反复圆点/出头线按新组更新,撤销可恢复 | C1 | +| ACC-V-006 | 输入错拍、错小节数、跨行/空行和声部一变二案例 | 有可定位诊断;错误对齐不部分生效;隐藏主声部与临时声部的显示/播放独立 | C1 | +| ACC-V-007 | 导入 8 个声部及超过已声明上限的文档 | 至少 8 声部完整保留并播放;超限不截断文档,播放受限时明确提示 | C0/C1 | + +### 5.1 附件、隐藏对象与跨边界 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-O-001 | 在歌词/弧线/房子/临时声部之前插入或删除音符 | 稳定锚点不随数组下标漂移;未涉及对象的关联保持 | C0/C1 | +| ACC-O-002 | 删除附件端点或锚定符号并撤销,另保存孤立状态工程 | 孤立附件保留并诊断,不猜测迁移;撤销恢复端点/载荷/顺序;工程保存重开保留孤立状态,语义不安全只阻止兼容导出 | 阻断 | +| ACC-O-003 | 让范围附件跨小节、跨行和跨页 | 同一语义对象按段绘制、端点/续接正确,不重复生成播放事件;保存仍是正确范围关系 | C1 | +| ACC-O-004 | 切换符号可见性,再切换打印/播放 | 可见、占位、播放标志按模型分别生效;隐藏不会意外删除时值/锚点,辅助提示不进入打印 | C1 | +| ACC-O-005 | 删除整小节、移动谱行、重新分页和调整临时声部位置 | 锚点保持或明确失效;没有静默跨声部绑定、重复附件或未报告裁切;再导入可复核 | C1 | +| ACC-O-006 | 导入 `@measure,line`、`@@ordinal`、`R`/偏移/未知端点形式 | 可解释形式正确解析;不确定形式保留源文及诊断,不强行套用另一种编号或迁移算法 | C0 | + +## 6. 固定页面和排版验收 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-L-001 | 新建 A4 文档 | 页面尺寸为 210×297 mm;SVG viewBox 和打印纸张一致 | 阻断 | +| ACC-L-002 | 使用默认边距 | 默认版心以 20 mm 配置为基线;页面设置可见可改 | C0 | +| ACC-L-003 | 导入康定情歌基线样例 | 小节、谱行和页数与基线报告可比;差异必须列出 | 阻断 | +| ACC-L-004 | 让一行超过版心 | 以小节为单位折行,不拆分不可拆小节 | 阻断 | +| ACC-L-005 | 改变字号/字体角色 | 重新测量并排版;不出现未报告重叠或裁切 | 阻断 | +| ACC-L-006 | 添加长歌词 | 音符间距按规则撑开;歌词不相互覆盖 | 阻断 | +| ACC-L-007 | 添加上方/下方附件 | 四向占位影响相邻符号和行高;页面无裁切 | C1 | +| ACC-L-008 | 切换左对齐/两端对齐,调整自动行距和段落间距 | 左对齐自然宽度不被拉满;两端对齐按已定末行规则靠近右版心;垂直间距生效,小节边界和锁定约束不被破坏 | C0/C1 | +| ACC-L-009 | 调整单个符号间距 | 产生锁定状态标记;自动重排不覆盖锁定值 | C1 | +| ACC-L-010 | 解锁单个符号或整行 | 目标恢复自动间距,其他锁定对象不受影响 | C1 | +| ACC-L-011 | 插入强制换行/移动小节,保存工程并重开 | 手动分行优先级可见,所有用户覆盖恢复;不以原格式不支持为失败借口;JPW 版式输出边界另测 ACC-X-013 | C1 | +| ACC-L-012 | 添加页面文本框 | 文本框按页面坐标显示;不撑开音符或行高 | C1 | +| ACC-L-013 | 缩放浏览器预览 | 页面物理比例不变;只改变屏幕缩放 | C0 | +| ACC-L-014 | 连续渲染同一输入两次 | SVG 结构、对象坐标和页数确定性一致 | C0 | +| ACC-L-015 | 缺失字体后重新渲染 | 记录实际替代字体;显示字体替换警告;不静默改变兼容状态 | C0 | +| ACC-L-016 | 导入短式/完整式 AW、Gap 及其单变量变体 | 原值和尾部保留;Web 映射、实际预设、警告可追溯;不要求复刻未知官方公式 | C0 | +| ACC-L-017 | 在固定输入上逐步增大 Web `baseGap`/歌词占位,改变版心宽度 | 固定行分配的自然宽度不反向减小;最终折行/分页可解释,无未报告重叠;不对未知 JPW 字段强加单调假设 | C0 | +| ACC-L-018 | 输入比版心更宽的单个不可拆小节 | 不拆坏音乐结构,不无限循环分页;显示可定位溢出并提供调整提示,不悄悄裁切导出 | 阻断 | +| ACC-L-019 | 手动锁定后切预设、改字体、强制换行,再解锁并保存工程 | 锁定优先级和冲突可见;解锁只影响目标;技术方案 §4.3 列出的手动状态全部重开等价,不得临时缩减可持久化清单 | C1 | +| ACC-L-020 | 多页、多声部、长歌词和跨页附件同时存在 | 版心/页眉页脚不冲突;行高含占位;页面文本框不挤占乐谱流;语义顺序不因分页改变 | C1 | + +### 6.1 基线目标 + +研究中记录的康定情歌参考结果为: + +- 16 小节; +- 1 页; +- 6 行; +- 230 个符号; +- 运行时 `L=9422`。 + +删除 `.Attachments` 的变体记录为 163 个符号、`L=2578`;删除 `.Words` 的变体记录为 152 个符号、`L=9116`。这些 `L` 值和参考软件符号统计不是 Web 内部必须复制的字段。验收要求是:对象差异方向和页面结构可解释,不得把未知对象误算成核心音符。 + +16 小节为必须一致的核心结构;1 页/6 行是固定字体、边距和预设下的参考目标。若合法替代字体或明确的 V/U 处理导致差异,必须逐项记录原因并获得具体差异批准,不能仅写“Web 自己的算法”或自动更新金样;支持范围内的漏对象、错小节不允许豁免。 + +### 6.2 布局与打印比较规则 + +1. **语义比较:**已支持音高、分数时值、小节/声部顺序、歌词锚点和附件关系精确一致;会话内与工程重开均保持稳定 ID。原格式重新导入允许新 ID,但关系须同构;JPW7 的 Web 版式差异按预先确认报告检查,不伪装成完整工程恢复。 +2. **Web 自身确定性:**固定模型、字体和配置得到相同规范化快照、页数/谱行数和 SVG;剔除生成时间等非语义元数据后比较,不能忽略实际坐标。 +3. **结构金样:**先核对对象/小节/谱行/页数及警告,再比较坐标;与 JPW7 参考的差异按具体对象解释,不要求未知内部 Bounds 数值相同。 +4. **数字输出几何:**SVG 的 A4 声明精确为 210×297 mm;PDF MediaBox 换算误差每边 ≤0.2 mm。相同 Web 快照中的可定位几何标记在 SVG 与 PDF 间偏差 ≤0.5 mm;文字抗锯齿差异单独判读。 +5. **截图比较:**固定字体、浏览器、DPR、缩放和加载完成状态;差分必须人工复核漏字、重叠、端点和裁切,不以单一像素差比例掩盖对象缺失。 +6. **实体打印:**用测试副本的 100 mm 标尺核对设备缩放并记录偏差;物理设备与驱动误差单列,不能把实体机差异当作数字 PDF 尺寸通过的证据。 + +### 6.3 字体和私有字形 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-G-001 | 字体延迟加载、失败、切换角色和重复排版 | 测量与实际绘制字体一致;加载完成前不将替代测量固化为最终结果;变化重新布局且有记录 | C0 | +| ACC-G-002 | 导入已观察的 35 个私有码位及未知码位 | 码位、原始字段、角色和锚点保留;缺字有可定位提示,不用无提示空白替代,不猜音乐语义 | 阻断 | +| ACC-G-003 | 检查生产包和网络资源 | 每种字体/音色有许可记录;不含未经授权的提取字体及研究资源,不把 fsType 位当作 Web 分发授权 | 阻断 | +| ACC-G-004 | 切换约定的标准/端庄/典雅/手写等音符风格 | 实际字体或字形实现、许可与替代状态可见;角色独立、重新测量、保存可复核,不冒称 JPW 私有字形逐字等价 | C0/C1 | + +## 7. 播放验收 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-A-001 | 首次点击播放 | 由用户手势启动 AudioContext;若被浏览器阻止,显示提示而不是静默失败 | 阻断 | +| ACC-A-002 | 播放普通单声部样例 | 音高、时值、休止和速度与模型事件一致 | 阻断 | +| ACC-A-003 | 暂停、继续、停止 | 暂停取消未来调度并处理当前持续音;继续按暂停位置恢复,无重复/漏播;停止后无残留音,状态和指示条一致 | 阻断 | +| ACC-A-004 | 点击中间音符后从光标播放 | 从目标符号对应的语义时间开始 | C0 | +| ACC-A-005 | 拖动进度条 | 跳转到正确小节;页面滚动和指示条同步 | C0 | +| ACC-A-006 | 修改速度/拍号/调号 | 后续时间线按新设置计算;已存在对象不被改写 | C1 | +| ACC-A-007 | 播放多声部样例 | 声部同时开始并按各自时值结束;节拍对齐 | C1 | +| ACC-A-008 | 切换声部静音/独奏/音量 | 音量/静音针对目标声部,独奏按选中集合决定可听声部;所有声部时间线不漂移 | C1 | +| ACC-A-009 | 播放标准弧线/倚音 | 采用对应播放语义;特殊视觉弧线不额外发声 | C1 | +| ACC-A-010 | 播放常见反复/房子/DC/DS | 播放顺序、次数和返回点与解析路径一致 | C1 | +| ACC-A-011 | 复杂或冲突反复 | 播放前显示降级/不确定提示,不用错误顺序冒充通过 | C1 | +| ACC-A-012 | 播放指示条显示/隐藏 | 只存在于屏幕辅助层,导出和打印不出现 | C0 | +| ACC-A-013 | 浏览器切后台、恢复或音频设备变化 | 状态可恢复或明确停止;不能卡死编辑器 | C0 | +| ACC-A-014 | 关闭播放再编辑 | 播放资源释放;编辑和再次播放可用 | C0 | +| ACC-A-015 | 8 个声部合奏并选择/切换基础音色、逐一静音/独奏/调整音量 | 无丢声部或时间线漂移;音色选择生效而音高/时值不变;容量不能以仅解析 8 声部替代 | 阻断 | +| ACC-A-016 | 快速重复播放/暂停/跳转/停止,再编辑文档 | 旧调度取消,无残留持续音或重复 AudioContext;界面与当前版本时间线一致;编辑时采用明确停止或重新编译策略 | C0 | +| ACC-A-017 | 分别播放标准连音、特殊弧线、倚音、和弦名称和柱式和弦 | 标准音乐语义生效;纯视觉对象不额外发声;跨行分段不会重复发声 | C1 | +| ACC-A-018 | 经过变速/换拍/转调/静态力度点,或从这些点后开始/反复跳回 | PRD-A-009 的时间、音高和控制状态正确;从光标或反复入口恢复有效速度/拍号/调号/声部力度,其他声部不串扰 | C1 | +| ACC-A-019 | 输入循环跳转、冲突反复、超长展开及未知 `cpo/PlayMode` | 有展开上限和可定位诊断;不卡死;保留原文,不把猜测播放路径标为已支持 | 阻断 | +| ACC-A-020 | 将源谱与随包 MIDI、临时声部不同播放模式对照 | 报告区分实际匹配维度与未验证维度;已支持模式按定义编译,未知模式提示;不由音高匹配推断时值/音色/逐轨完全等价 | C1 | +| ACC-A-021 | 单独输入倚音组、单上/下波音并保存工程重开再播 | 按 W1 金样比较事件数、音高、分数起点/时值;下一音不提前;未知邻音不能猜测,重开不改变规则 | C1 | +| ACC-A-022 | 重音、跳音、各档静态力度、支持组合及跨声部跳转 | velocity/gate 与 W1 金样精确一致,谱面总时值不变;实际发声体现力度/跳音,不只是事件标签;动态状态恢复且不串声部 | C1 | +| ACC-A-023 | 增删仅视觉标记,或输入未定义装饰组合/缺失音高锚点 | 仅视觉对象不改事件并提示;未定义组合明确拒绝不可靠播放但工程可保存;已承诺单项/组合不能一律拒绝 | C1 | + +### 7.1 时间线自动检查 + +除人工听感外,必须对 `PlaybackTimeline` 做自动断言: + +- 按语义时间线核对,前导休止/预备拍必须计入;第一发声事件不强制在第 0 拍。 +- 全局事件按起始时间稳定排序;同时发声事件允许相同起始时间,顺序具有确定性。 +- 休止符不生成音频事件但占用时间;隐藏符号是否发声由音乐语义决定,不能按是否有 SVG 节点推断。 +- 时值使用精确分数或等价刻度;小节总时值按拍号校验,弱起/散板须有明确模型,不当作普通满小节补音。 +- 已支持反复的路径、次数和事件总数可复现;循环检测和展开上限可测试。 +- 连音、柱式和弦、8 声部和控制事件的预期由人工可核算的最小案例确定,不能从被测编译器自生成预期。 +- 编译所得秒数与独立计算期望的误差 ≤1 微秒;这不是对物理声卡延迟的承诺。 +- 视觉对象数量变化不会无故改变播放时长;跳转后有效速度/调号/拍号及声部静态力度正确恢复。 +- 既有 MIDI 对照中的 358/358 等匹配只证明报告所述序列维度,不自动成为 Web 全事件相等的判据。 + +### 7.2 W1 播放金样 + +规则来源为[技术方案 §7.4](JPW7在线简谱编辑器-功能规格与技术方案.md#74-装饰与力度播放规则-w1),以下是独立人工期望,不是 JPW7 实测金样。基础输入:四分音符为 1 拍、C 大调、初始速度 60 BPM,主音 MIDI 60 在第 0 拍占 1 拍,后续音 MIDI 64 在第 1 拍占 1 拍;默认力度 80。 + +元组依次为 `(pitch, startBeat, scoreDurationBeats, gateBeats, velocity)`。除静态力度案例外,后续音保持 `(64,1,1,1,80)`。小数均用分数等价检查,不能四舍五入到整数拍。 + +| 金样 | 输入差异 | 必须得到的事件/行为 | +|---|---|---| +| W1-01 | 主音前单倚音 62 | `(62,0,1/4,1/4,80)`、`(60,1/4,3/4,3/4,80)`,主音组总长 1 拍 | +| W1-02 | 主音前两个倚音 62、64 | 两倚音起点为 0、1/8,各占 1/8;主音从 1/4 起占 3/4;音高顺序 62、64、60 | +| W1-03 | 单上波音,明确邻音 62 | `(60,0,1/4,1/4,80)`、`(62,1/4,1/4,1/4,80)`、`(60,1/2,1/2,1/2,80)` | +| W1-04 | 单下波音,明确邻音 59 | 同 W1-03 的时值,音高依次 60、59、60 | +| W1-05 | 重音 | 主音 velocity=100,gate=1;后续音 velocity=80,不把重音变成持续力度 | +| W1-06 | 跳音 | 主音为 `(60,0,1,1/2,80)`;后续音仍在第 1 拍,不在第 1/2 拍 | +| W1-07 | 分别设置 pp/p/mp/mf/f/ff | 对应 32/48/64/80/96/112,主音和后续音均取当前档;另检查 ff(112)+重音的限幅,主音结果 127、后续音保持 112 | +| W1-08 | 同时设置 f+重音+跳音 | 主音 `(60,0,1,1/2,116)`,后续音 `(64,1,1,1,96)` | +| W1-09 | 分别添加滑音/震音/复式波音/渐强渐弱/换气/和弦名称/特殊弧线/纯表情文字 | 原两个音符事件逐字段不变;按类型显示视觉或播放降级提示,不生成额外音符/音量曲线 | +| W1-10 | 改用 4 拍双声部,各有 4 个 1 拍音;A 在第 0 拍 p、第 2 拍 f,B 无力度事件;设从第 4 拍返回第 1 拍的合法反复路径 | 从第 3 拍开始 A=96、B=80;反复回到第 1 拍 A=48、B=80;速度/拍号/调号另按控制事件 fixture 独立预期检查 | +| W1-11 | 波音无确定邻音、倚音失去主音锚点、同锚点倚音+波音或延音+跳音 | 每种都诊断并阻止不可靠播放;保存工程、重开和修正后播放仍可用,未知原文/未决状态不丢失 | + +所有金样包含工程保存重开前后比较。对 `D≠1`、多倚音、局部变速各增加最小变体,以排除硬编码;拍→秒换算使用现有 §7.1 容差。力度/gate 还须通过固定音色的离线或回录检查确认实际音量/停音效果,声卡固定延迟另列,不能只凭时间线标签通过。 + +## 8. 导出、打印和再导入验收 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-X-001 | 导出一页 SVG | 尺寸固定、对象分组和数据 ID 存在 | C0 | +| ACC-X-002 | 导出多页 SVG | 每页独立尺寸、页码顺序正确 | C0 | +| ACC-X-003 | 导出 PNG | 无选区、锁定星号、版心线和调试框 | C0 | +| ACC-X-004 | 浏览器打印为 PDF | A4、缩放 100%、无裁切、无默认页眉页脚 | 阻断 | +| ACC-X-005 | 实体打印预览 | 页面边界和 PDF 预览一致;打印机差异有记录 | C0 | +| ACC-X-006 | 导出前打开辅助信息 | 辅助层可以看见;打印/PDF 自动隐藏 | C0 | +| ACC-X-007 | 导出兼容支持集 `.jpwabc` 后由 Web 重新导入 | 承诺的对象/未知块/页面字段/字体角色可比对;Web 专用布局差异与确认报告一致;此项不替代原程序回验 | 阻断 | +| ACC-X-008 | 导出含未支持对象的文档 | 明确告警或阻止导出;不得无提示删除 | 阻断 | +| ACC-X-009 | 对同一 LayoutSnapshot 导出 SVG 并打印 PDF | 页数/页序一致;A4 物理尺寸及对应几何符合 §6.2;不另建与屏幕不一致的排版结果 | 阻断 | +| ACC-X-010 | 在新浏览器上下文/离线查看器打开导出 SVG/PNG/PDF | 合法字体嵌入、许可允许的路径化或明确的替代方案有效;不依赖应用临时 URL;PNG 尺寸等于毫米×DPI/25.4 的取整值 | C0 | +| ACC-X-011 | 打印单页和多页,并执行一次实体打印路径 | 无空白尾页、重复页、选区或播放指示条;数字结果满足 §6.2;设备设置/标尺结果归档,无设备则标未执行 | C0 | +| ACC-X-012 | 按原格式支持清单执行新建/标题/音符时值歌词编辑/未知原文保留四类 Web→JPW7→Web 回验 | JPW7 不报错或意外修复;承诺语义与独立期望一致;原程序另存后 Web 仍等价;记录版本、输入输出哈希、操作和比较,不要求字节完全相同 | 阻断 | +| ACC-X-013 | 导出含 Web 手动覆盖、未映射语义或不安全未知锚点的工程,分别确认/取消 | 仅版式差异可在逐项报告并确认后输出副本;语义不安全则阻止 JPW 导出;工程始终可保存/重开且原状态不变;导出成功不清除工程脏状态 | 阻断 | + +ACC-X-012 的测试期望不得由 Web 解析器/序列化器互相生成。核心集见技术方案 §4.5;自建最小案例须包含真实编辑,不能只把导入原件不变返回来冒充写入兼容。无法运行原程序时结果为未执行,不能将必选项移出支持清单或仅写“文件能打开”作为通过证据。 + +## 9. 错误处理与无障碍验收 + +| 编号 | 操作 | 预期结果 | 级别 | +|---|---|---|---| +| ACC-Q-001 | 导入损坏文件 | 显示源位置和错误原因;页面保持可用或安全退出 | 阻断 | +| ACC-Q-002 | 触发布局溢出/重叠 | 显示可定位警告;不把问题隐藏在截图中 | 阻断 | +| ACC-Q-003 | 缺失字体/字体加载失败 | 显示替代字体和影响范围 | C0 | +| ACC-Q-004 | 键盘完成插入、撤销、播放和保存 | 主要操作不依赖鼠标 | C0 | +| ACC-Q-005 | 使用屏幕阅读器或可访问性检查 | 控件有名称、角色、状态和焦点顺序 | C0 | +| ACC-Q-006 | 仅改变颜色测试选区/播放状态 | 仍可通过文字、图标、边框或焦点识别 | C0 | +| ACC-Q-007 | 大文档连续编辑 | 按 §10 测量;页面更新达标,无卡死、模型失效和不可恢复错误 | C0 | +| ACC-Q-008 | 导入 JPW 或工程内含 HTML/SVG 脚本、事件属性、外部 URL 的文字/对象/源原文 | 仅作为数据处理;不执行脚本、不拼入不安全 DOM、不自动加载源文件或外部资源;页面和各类导出均安全 | 阻断 | +| ACC-Q-009 | 输入超出声明的文件/嵌套/对象/反复展开上限,取消导入或模拟存储失败 | 有界终止、明确提示、界面可恢复;不覆盖已打开文档和最近有效保存 | 阻断 | + +## 10. 性能目标 + +这些是 Web 产品目标,不是 JPW7 实测事实。以下计时、分位数和容差为本版执行口径,实施时须将原始测量值一并归档。 + +| 指标 | 测量方法 | 目标 | +|---|---|---| +| 10 页以内普通样例首次显示 | 固定生产构建/样例/字体,资源就绪后,从导入字节到首份完整 SVG;冷应用状态独立运行 20 次;另列网络/字体冷加载时间 | P95 ≤2 秒;不得仅测纯布局函数漏掉解析/渲染 | +| 单次常规编辑更新 | 预热后,对音符修改、歌词修改和插入/删除分别采样至少 100 次,计时至新画面完成 | 每类 P95 ≤100 ms;不能把重排样本剔除,仅因实现使用全量布局也不能豁免 | +| 播放调度 | 记录音频时钟、预调度窗口及已取消事件,快速暂停/跳转回归 | 按至少 100 ms 的预调度窗口运行;未来事件可取消,恢复无重复/漏事件 | +| 连续播放 | 10 分钟固定节拍测试,记录计划时刻与音频时钟;离线/回录检查实际发声并人工复核 | 无累计节拍漂移、持续音或掉音;时钟计划差不累计超过 20 ms;设备固定延迟单列,调度日志不能替代实际发声检查 | +| 连续编辑与资源释放 | 10 页案例连续编辑 30 分钟,反复打开/关闭和播放/停止;记录堆/节点/音频资源 | 无卡死;文档关闭后无遗留发声节点和持续调度;堆回收后的持续增长须解释并消除泄漏,GC 前峰值不是泄漏证据 | +| 导出增长趋势 | 相同对象密度的 1/5/10 页案例各运行至少 5 次,报告耗时/峰值内存 | 呈近线性趋势,不出现反复全量导出导致的明显超线性退化;失败不损坏原件且有错误报告 | + +任何性能豁免都须单列硬件/浏览器限制和批准记录,不能通过删除慢样例改变统计。以 GC 等原因判为异常的样本保留原值并解释。 + +## 11. 回归证据包 + +每次候选版本至少归档: + +```text +acceptance/// +├─ manifest.sha256 +├─ environment.json +├─ scope.json +├─ case-results.jsonl +├─ import-report.jsonl +├─ layout-report.jsonl +├─ playback-timeline.jsonl +├─ export-report.jsonl +├─ native-roundtrip-report.jsonl +├─ projects/ +├─ native-roundtrip/ +├─ performance.json +├─ resource-licenses.md +├─ screenshots/ +├─ svg/ +├─ png/ +├─ pdf/ +├─ failures.md +└─ report.md +``` + +`environment.json` 至少记录构建标识/提交(建立版本控制后)、浏览器和系统版本、机器规格、字体名称/哈希/实际替代、DPR、纸张/缩放、音频设备/采样率及测试工具版本。不得把用户私有音频、字体或文件内容上传到外部服务。 + +`scope.json` 记录本次 R0/R1、每项 PRD/ACC 的适用能力、固定原格式写入支持集和预先批准的差异;`case-results.jsonl` 每条至少有:用例号、fixture ID/哈希、环境、步骤、预期、实际、结果、证据路径、缺陷号和执行者/时间。目录名或截图存在不等于测试通过。 + +`projects/` 保存工程恢复的输入/输出;`native-roundtrip/` 保存 Web 导出和 JPW7 另存副本;对应报告记录原程序版本/哈希、独立预期、是否报错或修复、承诺字段逐项比较、版式差异确认,以及播放/操作证据。W1 记录工程规则版本、事件五元组和实际发声检查。缺少这些证据不能由 Web 自我 round-trip 抵扣。 + +### 11.1 单项执行记录模板 + +```text +用例编号 / PRD: +里程碑 / 能力维度: +构建标识 / 环境: +输入 fixture / SHA-256: +前置条件(字体、页面、速度、声部等): +操作步骤: +预期结果 / 允许差异: +实际结果 / 测量值: +结论:通过 / 有条件通过 / 失败 / 未执行 / 不适用 +模型、布局、时间线、输出、截图证据路径: +缺陷编号 / 严重性 / 复测结果: +执行者 / 时间 / 差异批准记录: +``` + +### 11.2 版本验收报告模板 + +```text +版本 / 构建 / 验收日期: +目标:R0 首期可用版 / R1 目标版 +环境及资源许可报告: +必选用例:总数 / 通过 / 失败 / 未执行(必选不得不适用) +范围外及预定降级用例:数量 / 原因 / 批准记录 +33 样例覆盖:已运行数量 / 各环节被阻止数量及原因 +P0/P1(含混合 P0/P1)需求追踪:完成项 / 未完成项 +工程完整恢复 / 原格式核心和已承诺扩展写入集回验结果: +W1 逐类型/组合/仅视觉播放检查结果: +阻断与严重缺陷:数量 / 明细 +布局、打印、性能和播放测量摘要: +兼容性差异、已知限制和支持声明: +证据包路径 / manifest 哈希: +结论:通过 / 不通过(不能用“调研完成”替代) +验收负责人 / 日期: +``` + +### 11.3 端到端必跑场景 + +| 场景 | 操作链 | 里程碑 | +|---|---|---| +| E2E-01 新建歌谱 | 新建→录入标题/旋律/歌词→撤销重做→播放→保存工程→关闭重开→SVG/PNG/PDF | R0/R1 | +| E2E-02 迁移旧谱 | 33 样例批量分类;主样例打开→定位警告→改音符/标题→重排→保存工程比较→打印 | R0/R1 | +| E2E-03 安全降级 | 未知控制/私有码位→提示→尝试影响锚点的修改→确认保留或阻止该操作→保存工程→重开比较 | R0/R1 | +| E2E-04 合奏基础 | 8 声部输入→同时播放→静音/独奏→跳转/暂停→保存工程→重开 | R0/R1 | +| E2E-05 复杂编辑 | 多段歌词+临时声部+跨页附件→删端点/撤销→锁定间距/强制换行→保存工程→重开恢复→尝试兼容导出并检查差异 | R1 | +| E2E-06 扩展播放 | 反复/房子/DC/DS+谱内控制+W1→完整播放→从光标开始→跳回→事件金样比较→工程重开复测 | R1 | +| E2E-07 多页输出 | 长谱→字体替代→检查溢出→导出独立打开→单/多页 PDF→实体打印路径 | R0/R1;C1 对象在 R1 扩展 | +| E2E-08 原格式兼容 | 核心集新建/编辑→JPW 兼容预检/确认→导出→原程序打开检查/另存→Web 再导入比较;工程脏状态不被误清除 | R0/R1;R1 再覆盖已承诺扩展写入集 | + +### 11.4 阶段验收对应 + +| 开发阶段 | 主要证据 | 阶段完成不代表什么 | +|---|---|---| +| M0 | 工程可运行、fixture manifest、范围/资源/原格式写入清单与回验环境 | 不代表应用已有功能 | +| M1 | ACC-F 的 codec/parser/写回自动检查、33 样例分类 | 解析成功不代表可编辑或正确显示 | +| M2 | 命令、锚点、时值、ProjectCodec 版本校验与工程恢复 | 模型正确不代表界面可操作 | +| M3 | ACC-L/ACC-G 布局、SVG 和 PDF 探针 | 截图相近不代表数据/播放正确 | +| M4 | 核心编辑 E2E、脏状态、键盘/无障碍记录 | 可编辑不代表完整导出/音频闭环 | +| M5 | R0 所有必选项及 E2E-01~04/07~08、原程序回验与证据包 | R0 通过不代表 C1 目标版完成 | +| M6 | ACC-E/T/O/G 复杂对象检查 | 视觉装饰通过不代表其全部可播放 | +| M7 | ACC-V、手动布局/跨页的工程恢复与 ACC-X-013 | 原格式不能携带手动覆盖,不等于工程可以丢失它 | +| M8 | ACC-A 时间线/反复/控制/W1 金样与 E2E-06 | MIDI 音高对齐不代表逐事件等价,视觉通过不代替 W1 | +| M9 | R1 全量用例、跨浏览器/性能/安全/许可和发布报告 | 目标版完成不代表 P2 或私有算法已复刻 | + +## 12. 已收束研究的实现与兼容边界 + +**这里不是待调研清单,也不阻止开发。** 依据 [remaining-gap-closure.md](../research/jpw7/remaining-gap-closure.md),各项已有证据、保守 Web 决定及明确的非承诺范围。 + +| 主题 | 已归档结论 | 验收本产品什么 | 不宣称什么 | +|---|---|---|---| +| AW/Gap、Pos/Bounds | 有预设、字段与变体结果,官方公式/逐符号数值未恢复 | 可配置阶段布局、字段保留、占位/分页/确定性和差异报告 | 已恢复 JPW7 官方公式或全部坐标 | +| JPW-ABC 控制码与错误 | 有语法库存、控制码卡片和保留策略 | 观测语料全覆盖分类、核心语义、未知保留、错误定位和稳定保存 | 全量私有语法/版本的错误行为均与原程序相同 | +| 附件/隐藏/跨边界/临时声部 | 有载荷保留、锚点重写和原生删除证据,完整迁移算法未恢复 | 稳定锚点、端点诊断、跨行分段、可见性/播放分离和撤销 | 黑盒未证明的自动迁移或裁剪算法等价 | +| `.Options` 与手动状态 | 应用选项写入 `JpwOptions.cfg`;原文保留已有证据;Web 工程格式已获确认 | 工程完整恢复,原格式支持集写回/原程序回验,未映射版式确认与未知字段保留 | Web 全部布局都能装进 `.jpwabc` 或已恢复完整私有 schema | +| 字体 | `JpwCore`/`JPW_TaoDi` 身份已确认;35 个实际码位有图谱;Web 分发权未确认 | 字体角色、合法替代、码位保留、资源许可和测量一致性 | 身份仍未知,或提取成功即获得 Web 分发授权 | +| 播放 | 后端及 4 个 MIDI/部分源谱序列对照已归档;Web 采用 W1 明确规则 | 8 声部、谱内控制状态、常见反复、W1 事件/实际发声及可靠调度 | W1 是 JPW7 算法,或所有 `cpo/PlayMode`、DirectMusic 和高级媒体行为完全等价 | +| 打印 | 已有真实 A4 单页 PDF;参考免费版只打印第一页 | Web 自身单/多页 SVG、PDF、PNG 和打印几何 | 已证明 JPW7 全部 GDI/EMF 多页链路或共享布局缓存 | + +使用“按已定义 Web 策略通过”与“参考软件该行为已实测”两种独立结论。只有后续新增产品承诺确实需要额外证据时,才单独安排补充实验;不能让这些边界反复变成整个应用开工的前置条件。 + +## 13. 研究完成度对照 + +研究资料已覆盖核心验收所需的功能来源: + +- `research/jpw7/help-txt/JPW图文教程-快速入门.txt`:端到端操作入口; +- `research/jpw7/help-txt/JPW图文教程-怎样打歌谱.txt`:音符、歌词、弧线、反复、速度和转调; +- `research/jpw7/help-txt/JPW图文教程-怎样排版.txt`:间距、占位、折行、分页和锁定; +- `research/jpw7/help-txt/JPW图文教程-怎样打合唱谱.txt`:多声部、对齐、连谱号和声部名称; +- `research/jpw7/help-txt/JPW图文教程-怎样使用临时声部.txt`:临时声部、歌词、字体和对齐; +- `research/jpw7/help-txt/JPW图文教程-怎样打器乐谱.txt`:扩展符号、特殊文字、锣鼓经和器乐场景; +- `research/jpw7/help-txt/JPW图文教程-怎样打戏曲谱.txt`:复杂标题、谱字混排、散板和自由反复; +- `research/jpw7/help-txt/JPW图文教程-播放、动态谱.txt`:音色、声部、反复、速度、指示条、MIDI/MP3 和动态谱边界; +- `research/jpw7/final-audit.md`、`research/jpw7/evidence-matrix.md`:证据等级和未验证项。 + +结论:现有研究已足够启动并验收既定 Web 方案,R0/R1 按本标准逐阶段实现、运行和归档。精确私有算法与 P2 等价不是默认交付承诺,也不是需要重新完成的泛化调研;开发完成与否以实际用例和证据为准。 diff --git a/docs/JPW7在线编辑器-文档索引.md b/docs/JPW7在线编辑器-文档索引.md new file mode 100644 index 0000000..895a21f --- /dev/null +++ b/docs/JPW7在线编辑器-文档索引.md @@ -0,0 +1,87 @@ +# JPW7 在线编辑器文档索引 + +本组文档交付产品定义、技术方案、开发步骤、兼容边界和验收标准;调研已收束,当前仓库尚未包含在线编辑器实现。 + +## 开发执行(推荐先读) + +- [`JPW7在线简谱编辑器-开发步骤计划.md`](JPW7在线简谱编辑器-开发步骤计划.md) + - v1.1:M0–M9 的任务、依赖、交付物和出门条件;用户确认的工程保存/兼容导出分离、R1 谱内控制范围与需求追踪。 +- [`JPW7在线简谱编辑器-验收标准.md`](JPW7在线简谱编辑器-验收标准.md) + - v1.2:123 项 ACC 用例、8 条端到端流程、33 样例覆盖、工程恢复、JPW7 外部回验、W1 播放金样,以及阶段门槛与报告模板。全部为待执行标准,不是已通过结果。 + +## 产品与技术 + +1. [`JPW7在线简谱编辑器-产品需求文档.md`](JPW7在线简谱编辑器-产品需求文档.md) + - 产品定位、用户、版本范围、功能需求、播放目标和非目标。 +2. [`JPW7在线简谱编辑器-功能规格与技术方案.md`](JPW7在线简谱编辑器-功能规格与技术方案.md) + - `JPW-ABC → ScoreModel → LayoutEngine → SVGRenderer` 架构;§4.3~4.5 为工程/兼容导出契约,§7.4 为装饰/力度播放 W1 的唯一规则。 +3. [`JPW7在线简谱编辑器-兼容性矩阵.md`](JPW7在线简谱编辑器-兼容性矩阵.md) + - 参考软件事实、Web 承诺、支持级别和未验证项的逐项边界。 +4. [`JPW7_WEB排版实现参考.md`](JPW7_WEB排版实现参考.md) + - 固定页面、SVG 排版和参考实现调研。 + +## 验收与证据 + +5. [`JPW7在线简谱编辑器-验收标准.md`](JPW7在线简谱编辑器-验收标准.md) + - 文件、语义、排版、播放、导出、无障碍、性能和发布门槛。 +6. [`JPW7_实现原理与JPW-ABC语法调研.md`](JPW7_实现原理与JPW-ABC语法调研.md) + - JPW-ABC 区块、对象模型、帮助文档事实和实现推断。 +7. [`../research/jpw7/evidence-matrix.md`](../research/jpw7/evidence-matrix.md) + - 事实/推断/未验证/Web 建议的证据矩阵。 +8. [`../research/jpw7/final-audit.md`](../research/jpw7/final-audit.md) + - 调研完成度、实验结果、哈希和剩余阻塞项。 +9. [`../research/jpw7/advanced-playback-verification.md`](../research/jpw7/advanced-playback-verification.md) + - 高级播放、动态谱、样例交叉核对和本次黑盒复核边界。 +10. [`../research/jpw7/technical-gap-register.md`](../research/jpw7/technical-gap-register.md) + - 第一轮语法、对象、选项、字体和播放指令库存,以及剩余 P0 缺口。 +11. [`../research/jpw7/control-code-observations.md`](../research/jpw7/control-code-observations.md) + - `{J}`、`{C}`、`cpo:PV/PR/PO`、`PlayMode*` 和歌词扩展头的观察卡片。 +12. [`../research/jpw7/binary-feature-index.md`](../research/jpw7/binary-feature-index.md) + - 二进制宽字符串暴露的编辑、排版、播放、导出窗体入口;仅作补充证据。 +13. [`../research/jpw7/private-font-verification.md`](../research/jpw7/private-font-verification.md) + - 从 PE 内嵌资源提取并验证 `JpwCore`/`JPW_TaoDi` 字体及 cmap 信息。 +14. [`../research/jpw7/options-persistence-findings.md`](../research/jpw7/options-persistence-findings.md) + - `.Options` 字段、页面/预设实验和应用级/文档级写回边界。 +15. [`../research/jpw7/options-writeback-verification.md`](../research/jpw7/options-writeback-verification.md) + - `JpwOptions.cfg` 应用级写回、重启持久化及文档副本不变的三联实验。 +16. [`../research/jpw7/runtime-pathtext-analysis.md`](../research/jpw7/runtime-pathtext-analysis.md) + - GDI+ `pathtext`、`JpwCore` 字体预热和 Bounds/ABI 追踪边界。 +17. [`../research/jpw7/object-relocation-diff.md`](../research/jpw7/object-relocation-diff.md) + - 单个附件、歌词区块和音符删除后的页面/符号/Pos/Bounds 差分。 +18. [`../research/jpw7/program-options-ui-inventory.md`](../research/jpw7/program-options-ui-inventory.md) + - 程序选项九个页签、默认开关、排版和播放配置。 +19. [`../research/jpw7/playback-backend-verification.md`](../research/jpw7/playback-backend-verification.md) + - DirectMusic、Microsoft GS Wavetable Synth、BassMidi 和音色后端边界。 +20. [`../research/jpw7/midi-export-verification.md`](../research/jpw7/midi-export-verification.md) + - MIDI 按声部分轨、播放/出谱策略、独奏筛选和创作版参数。 +21. [`../research/jpw7/midi-event-inventory.md`](../research/jpw7/midi-event-inventory.md) + - 随包 MIDI 的 track、音符、歌词、Program Change、CC、tempo 和时间签名实测。 +22. [`../research/jpw7/midi-event-inventory.json`](../research/jpw7/midi-event-inventory.json) + - 同一 MIDI 清单的机器可读摘要。 +23. [`../research/jpw7/private-glyph-semantics.md`](../research/jpw7/private-glyph-semantics.md) + - 35 个实际使用私有区字形的图谱、上下文、元数据和 Web 许可边界。 +24. [`../research/jpw7/object-relocation-cross-boundary.md`](../research/jpw7/object-relocation-cross-boundary.md) + - 跨小节 `Branch/Arc/Brace` 删除、重锚点和状态栏差分。 +25. [`../research/jpw7/remaining-gap-closure.md`](../research/jpw7/remaining-gap-closure.md) + - 五类未闭环事项的事实、推断、未验证边界和 Web 处理决定。 +26. [`../research/jpw7/midi-source-prefix-alignment.md`](../research/jpw7/midi-source-prefix-alignment.md) + - 《十五的月亮》源谱音高前缀与随包 MIDI 主轨的实际对齐。 +27. [`../research/jpw7/midi-source-prefix-alignment.json`](../research/jpw7/midi-source-prefix-alignment.json) + - 源谱/MIDI 前缀对齐的机器可读结果。 +28. [`../research/jpw7/midi-source-to-midi-alignment.md`](../research/jpw7/midi-source-to-midi-alignment.md) + - 按反复标记展开后的全曲主轨序列匹配。 +29. [`../research/jpw7/midi-source-to-midi-alignment.json`](../research/jpw7/midi-source-to-midi-alignment.json) + - 全曲序列匹配的机器可读结果。 +30. [`../research/jpw7/logs/native-relocation/README.md`](../research/jpw7/logs/native-relocation/README.md) + - 原生菜单删除/保存、附件载荷保留和锚点重写证据。 +31. [`../research/jpw7/logs/print-real/README.md`](../research/jpw7/logs/print-real/README.md) + - Microsoft Print To PDF 单页产物、哈希和 StartPage/EMF 未验证边界。 + +## 结论 + +- 调研已收束,直接按开发计划进入实现与回归,不重新将五类研究边界列为开工前的泛化调研任务。 +- R0 完成核心闭环,R1 完成既定 P0+P1;C1 不能仅登记为未实现就算目标版通过。用户已确认谱内变速/换拍/转调纳入 R1,PRD-A-009 与各文档同步。 +- `.jianpu.json` 保存完整 Web 工程;`.jpwabc` 另作限定支持集的兼容导出并做原程序回读,不能以原格式映射不足阻止完整工程保存。 +- 装饰/力度不再以未枚举的“部分播放”验收:W1 明列可播放、仅视觉和拒绝条件,且不声称是 JPW7 实测演奏算法。 +- 精确私有公式、部分对象迁移、私有字形语义/许可和参考程序完整媒体/打印链路仍保留证据边界;Web 按已定义策略验收,不宣称这些内部行为已复刻。 +- 原始研究文档保留当时的实验与失败记录;当前执行口径以开发计划、验收标准和最终研究收束记录相互核对。 diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..627f42b --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,8 @@ +# M0 样例基线 + +`manifest.json` 是研究语料的只读基线,只引用 `research/jpw7/demo/` 下的 33 个 `.jpwabc` 原件及其 `research/jpw7/decoded/` 对照文本。 + +- 不在 Web 工程中改写原件。 +- 每个条目包含原件和解码文本的相对路径、字节数、SHA-256 与待执行的 M1 分类状态。 +- 运行 `npm run fixtures:manifest` 可重新计算并检查哈希;生成结果必须经审阅后提交。 +- M1 才会产生语法分类、未知内容和诊断结果;M0 不把库存报告冒充解析通过。 diff --git a/fixtures/manifest.json b/fixtures/manifest.json new file mode 100644 index 0000000..43f9ac9 --- /dev/null +++ b/fixtures/manifest.json @@ -0,0 +1,878 @@ +{ + "manifestVersion": 1, + "purpose": "M0 read-only fixture baseline", + "sampleCount": 33, + "generatedBy": "scripts/generate-fixture-manifest.mjs", + "sourceRoots": { + "raw": "research/jpw7/demo", + "decoded": "research/jpw7/decoded" + }, + "samples": [ + { + "id": "JP-Word介绍-播放功能", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word介绍-播放功能.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word介绍-播放功能.jpwabc.txt", + "rawBytes": 8910, + "decodedBytes": 5199, + "rawSha256": "eee44ca5a4706cd3d627dbb6aa4ebdb73eaaecd3f0d8f9cc4aca6c0739e1e609", + "decodedSha256": "7cab8d2e38a56bece317e837501db4bfba785d3df89e10dd5f15557c21ade5a3", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word介绍-创作版简介", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word介绍-创作版简介.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word介绍-创作版简介.jpwabc.txt", + "rawBytes": 7740, + "decodedBytes": 5256, + "rawSha256": "1fe7d561199bf03b4e1e63261e581f47e395a56ad7ed0ba4200efa210587e45c", + "decodedSha256": "85523eec627fa0e58c56aa74056645d9f47e19f8f4289c3aa7c71c4761fef218", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word介绍-功能简介", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word介绍-功能简介.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word介绍-功能简介.jpwabc.txt", + "rawBytes": 4602, + "decodedBytes": 2995, + "rawSha256": "ea43b055ecf18506bc6257446be161337af32eea4b64b653a96310ef66e41679", + "decodedSha256": "56875338d641773b6c27c893750328a17fbf89fc2103630d850dabdc6fed782f", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word介绍-谱字混排", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word介绍-谱字混排.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word介绍-谱字混排.jpwabc.txt", + "rawBytes": 6788, + "decodedBytes": 4998, + "rawSha256": "afc927f1f60c1270db3db0699d465572f6a0e7dad81053a680ef03fc2c081ff7", + "decodedSha256": "b7fad06aa6229147f467d7281d706c794cca2de4aa97dedcf23d375f7c02dd8b", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word介绍-器乐谱综合示范", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word介绍-器乐谱综合示范.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word介绍-器乐谱综合示范.jpwabc.txt", + "rawBytes": 35406, + "decodedBytes": 17823, + "rawSha256": "c1c48e1895687bfad13f19e78eaaff5f06bcf7d8b5517bcd6b8f1a9cbf2ba094", + "decodedSha256": "c21c3c11ec5041945db8d01cc2dd448a2dc0660df97e4fb67ccc6f00a70ef618", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word介绍-声乐谱综合示范", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word介绍-声乐谱综合示范.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word介绍-声乐谱综合示范.jpwabc.txt", + "rawBytes": 14110, + "decodedBytes": 8239, + "rawSha256": "9b2ad948e22301294365501ee3bd2e59353e4b8c7430cfbe3ae2f9023f19d425", + "decodedSha256": "04b9ab9a4891c4b7228a48987c453e03b8713d4d5dfeb82a3a0dbcefd73b21c5", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word练习曲-歌曲-康定情歌", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word练习曲-歌曲-康定情歌.jpwabc.txt", + "rawBytes": 19150, + "decodedBytes": 10992, + "rawSha256": "3a09c2ad5559476d2953670da3c8e1f7ab0908b8219f43f7ec9e34cba958361f", + "decodedSha256": "5eb7916843840213007ab5bbf96493ef194d671851efa4f8c1a5023246513828", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word练习曲-重唱-夫妻双双把家还", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word练习曲-重唱-夫妻双双把家还.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word练习曲-重唱-夫妻双双把家还.jpwabc.txt", + "rawBytes": 3644, + "decodedBytes": 2354, + "rawSha256": "15babaf4e2e3366b34855d6abf0f9e2fc90a2b5cbebca10aba278b8f65fc722c", + "decodedSha256": "1df674fba736c6401c857a2ca80d70718f3890a0fd89a349090b2dcfaae53d23", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-爱拼才会赢", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-爱拼才会赢.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-爱拼才会赢.jpwabc.txt", + "rawBytes": 8248, + "decodedBytes": 4539, + "rawSha256": "4ca2d891d35f2f021933db15d3fc311036cae60c93173692f6b24e6218fc0988", + "decodedSha256": "ef5b4a9d09e9170547caa08765a8eb54caccb97ce5140ff250fd3a80c2213b19", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-北国之春", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-北国之春.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-北国之春.jpwabc.txt", + "rawBytes": 7806, + "decodedBytes": 4359, + "rawSha256": "a72d304cd071a039b6e9509c9d9f80eb7eb51d12aa92c8c6ac606dc279976985", + "decodedSha256": "52ed8913799c9f33d04d0ac09f8f488f6cd9696aed9acdaa75029354aaf1e626", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-孤勇者", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-孤勇者.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-孤勇者.jpwabc.txt", + "rawBytes": 18662, + "decodedBytes": 10225, + "rawSha256": "20063ae4ab66be0e67b0a14268c273bccb1d4d644c49be82577fb8a81b1709e1", + "decodedSha256": "9ee601488d18f1817b8e1e2a4fc77300eb8e6cf7628f7af16c667d073def8514", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-好运来", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-好运来.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-好运来.jpwabc.txt", + "rawBytes": 7454, + "decodedBytes": 4228, + "rawSha256": "354ccb1f424278e93236acc1cebc0e01e20b27c6f0ec346f3f8ae9d368971efd", + "decodedSha256": "16ce6d8ecf7674e9033f9c4583be475ba1f064e14833bfa30b396900c61de8fe", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-凉凉", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-凉凉.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-凉凉.jpwabc.txt", + "rawBytes": 16156, + "decodedBytes": 9067, + "rawSha256": "0a73e4ccdb84a9245427746d5789826293c3f97f8ea1eaae379210a220762129", + "decodedSha256": "8d3cf667a092aee1492794622ce1ab9fb0f05a20765418040d30f6e73c048d7c", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-铃儿响叮当", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-铃儿响叮当.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-铃儿响叮当.jpwabc.txt", + "rawBytes": 7902, + "decodedBytes": 4589, + "rawSha256": "c460c9b1ea3aa296c010db7d4e8a08cd34c91e38c390602f88b23dbcd94f0f66", + "decodedSha256": "95cd2abab7444ca3a248be289f9c29f0eb76244b48e0678740f126edf6ead5d8", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-千里之外", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-千里之外.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-千里之外.jpwabc.txt", + "rawBytes": 6602, + "decodedBytes": 3804, + "rawSha256": "858f8e2b1f4ddfa008816db9fc3776559471a65352fda6b7297681522514e504", + "decodedSha256": "b833db0b644595291e9354333f479be29d1a764e6c671de7a3d047a3e361caab", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-十五的月亮", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-十五的月亮.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-十五的月亮.jpwabc.txt", + "rawBytes": 7550, + "decodedBytes": 4586, + "rawSha256": "7668c07b3b732d3c3b02d45edd8894f6daae2d7bc84e9fef3910638427a09e69", + "decodedSha256": "45f3aeab75f0c143c104ff806b9b7037418584902c508c497d44ad447fec7bf4", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-小苹果", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-小苹果.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-小苹果.jpwabc.txt", + "rawBytes": 7114, + "decodedBytes": 4316, + "rawSha256": "45d767aad6eb3cef228c9dec88764ea61f56bfd73aa297504f9f5175cb0e1a9c", + "decodedSha256": "e82aecbc5bd205eb4fcee505722332b7e6ea59bc8f2041d1147ea53670845c95", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-祝你生日快乐", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-祝你生日快乐.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-祝你生日快乐.jpwabc.txt", + "rawBytes": 4944, + "decodedBytes": 2702, + "rawSha256": "20a5fb1a9b58e64db34e9d3265af96156f2c823e73a68d1da283d4139d6217a0", + "decodedSha256": "a0800936d43f849ff4e06e9201e18bc494c2f40c47a10c34d7cee999d15406ff", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-歌曲-最亲的人", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-歌曲-最亲的人.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-歌曲-最亲的人.jpwabc.txt", + "rawBytes": 6288, + "decodedBytes": 3678, + "rawSha256": "71a63872c4549d2145ae59ef644cbe510911bfed0e5d6b9ab70c7b9e599f943b", + "decodedSha256": "f51050d6d81752f12907a03f44b5dd0db22b54e8798558c09e8b890f3df6f0fe", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-合唱-半个月亮爬上来", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-合唱-半个月亮爬上来.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-合唱-半个月亮爬上来.jpwabc.txt", + "rawBytes": 23810, + "decodedBytes": 12227, + "rawSha256": "3c8b1e52c1493de2998a5a796584b65f26d2d05c37d200a0ab5d6707f65bd962", + "decodedSha256": "c140aa39d98fd6fcd4136ca533df313bceed4994c77c37d45b72a76a1ca0758a", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-合唱-你笑起来真好看", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-合唱-你笑起来真好看.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-合唱-你笑起来真好看.jpwabc.txt", + "rawBytes": 12648, + "decodedBytes": 6899, + "rawSha256": "7fa3d6c00fe8315d60370bd0ee9c6af3a489f506a0328a1f02dccd200fc1cf30", + "decodedSha256": "ec529284c9e465ea126f79475becd0235a73a5e74dd13292e5aa4450b7e73b98", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-合唱-我和我的祖国", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-合唱-我和我的祖国.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-合唱-我和我的祖国.jpwabc.txt", + "rawBytes": 7234, + "decodedBytes": 3893, + "rawSha256": "75a6deebe4fd2fd5d54ec561203c47b141da34e45059c87b5105b49bd8663ab1", + "decodedSha256": "4fd9b354ca14bebdc96630994230e0acbd6809ea1163f4b8320a560abe2e1a5a", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-合唱-长江之歌", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-合唱-长江之歌.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-合唱-长江之歌.jpwabc.txt", + "rawBytes": 7940, + "decodedBytes": 4310, + "rawSha256": "668c77fff2397d865bda1093421fccacc05df4c2a985f82186b069a381719941", + "decodedSha256": "10aad03ad1245c32eeb7f155f53bd1a71a687e73d2f9825643b16c00a8b97602", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-京剧-秦王做事真无理", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-京剧-秦王做事真无理.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-京剧-秦王做事真无理.jpwabc.txt", + "rawBytes": 8146, + "decodedBytes": 4465, + "rawSha256": "f9adbd52559b3fa52be71e3ad3417547ffb502cf940a1c743cf4f5cf087fda38", + "decodedSha256": "b64fcf2c2b4ddad2ef65d77f0007ada59afae5987094b2034bc8fe1a890a1b88", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-爱你在心口难开", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-爱你在心口难开.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-爱你在心口难开.jpwabc.txt", + "rawBytes": 40580, + "decodedBytes": 20537, + "rawSha256": "b50621926bcfda29573d910c3f5b07961213356937f29a4f2a184cd0f5493b98", + "decodedSha256": "9d08faf9c75d240dc6271f5d3b1807a2e79ed0c2e25bbcb986bc864744c86276", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-春天在哪里(钢琴)", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-春天在哪里(钢琴).jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-春天在哪里(钢琴).jpwabc.txt", + "rawBytes": 19482, + "decodedBytes": 9664, + "rawSha256": "25afa50ec7af31f3292e20d07d43bee67380b49760022c1c41c82367f0b9ef40", + "decodedSha256": "cf113d9bb2815e830d92fdab2e67df15568db57fac74543d49bc8268b6708760", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-婚誓(民乐)", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-婚誓(民乐).jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-婚誓(民乐).jpwabc.txt", + "rawBytes": 16850, + "decodedBytes": 8650, + "rawSha256": "fd598e696fa109c7e3b235a7fb1b922705018c1613c0ef9187a812ad7a3b2578", + "decodedSha256": "6d7e44f29d1250b8559a6cd29f7ff314a3f2ebeb91544513186ee5a41826685c", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-假如你要认识我(手风琴)", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-假如你要认识我(手风琴).jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-假如你要认识我(手风琴).jpwabc.txt", + "rawBytes": 32062, + "decodedBytes": 16218, + "rawSha256": "c607373edad8af4f758b94bfeb53f168c26c34f5720769a0d781c54bc5cb9c6b", + "decodedSha256": "6705d7cab30bea4a4202bd0f0fc95a3534e4cd7f87d2dc5b8a96717ecae1804d", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-赛马(二胡)", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-赛马(二胡).jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-赛马(二胡).jpwabc.txt", + "rawBytes": 45080, + "decodedBytes": 22492, + "rawSha256": "56e1275f69dcc19474933a07db05585ecce7b6c28b483e56cd9ff2da67334650", + "decodedSha256": "952b44e3db9768e46b5b3afd5f324031e6086d28eda2a2f764c346b2f4218947", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-小城故事(钢琴)", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-小城故事(钢琴).jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-小城故事(钢琴).jpwabc.txt", + "rawBytes": 21340, + "decodedBytes": 10622, + "rawSha256": "edbb9c747baf4894708ee5ef8382041de2986749d1244d3a4f039e23d75f4eec", + "decodedSha256": "4d67cf903c7632e6e83dcf00ca788437b6af3de2eeec193310bf87f8727b1e60", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-器乐-友谊地久天长(陶笛)", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-器乐-友谊地久天长(陶笛).jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-器乐-友谊地久天长(陶笛).jpwabc.txt", + "rawBytes": 20590, + "decodedBytes": 10234, + "rawSha256": "e610d72e41f26bf706f8ebea3bfa1807f017cd0583c3e7b6b58e0e774fc285eb", + "decodedSha256": "9a52049e15edceb4f8c4d36a0511cc15cccfa6f4731a8b7411a7b039f8d1372e", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "JP-Word示范曲-戏曲-天上掉下个林妹妹", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/JP-Word示范曲-戏曲-天上掉下个林妹妹.jpwabc", + "decodedPath": "research/jpw7/decoded/JP-Word示范曲-戏曲-天上掉下个林妹妹.jpwabc.txt", + "rawBytes": 11324, + "decodedBytes": 6086, + "rawSha256": "eddb049774c71045ac890b22f3294fed8b23708d2ced9ff6acd4e4095298eb45", + "decodedSha256": "eac673b2668f3bca72e6208406d074aa96be4cc8284c9976056d49121772d928", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + }, + { + "id": "mp3mp4::mp3同步练习-康定情歌", + "sourceReadOnly": true, + "rawPath": "research/jpw7/demo/mp3mp4/mp3同步练习-康定情歌.jpwabc", + "decodedPath": "research/jpw7/decoded/mp3同步练习-康定情歌.jpwabc.txt", + "rawBytes": 4620, + "decodedBytes": 3274, + "rawSha256": "e590eadac1465eb7ce00d70fdfcef06c04a68089b0e40593b3d9d1ef827808e5", + "decodedSha256": "21fff1eca610911e6a8bc329f2577266ec42478cba0931388628e44e92cc48dc", + "classification": { + "status": "parsed-with-diagnostics", + "sourcePreserved": true, + "parser": "pass", + "model": "pass", + "layout": "pass", + "projectRoundtrip": "pass-33-of-33", + "svg": "pass", + "unsafeCompatibilityExport": "blocked-safely", + "firstResponsibleMilestone": "M1", + "evidence": [ + "acceptance/m1/sample-classification.json", + "acceptance/m9/summary.json", + "acceptance/m9/case-results.jsonl" + ] + } + } + ], + "classificationSummary": { + "status": "all-33-classified-with-pipeline-statuses", + "statusCounts": { + "parsed-with-diagnostics": 33 + }, + "sourceBytesPreserved": true, + "evidence": "acceptance/m1/sample-classification.json; acceptance/m9/summary.json" + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..6cd5cee --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + JPW7 在线简谱编辑器 + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..22871f2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "jianpu-web-editor", + "version": "0.0.0-m0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jianpu-web-editor", + "version": "0.0.0-m0", + "devDependencies": { + "typescript": "5.9.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9a4e3c --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "jianpu-web-editor", + "version": "0.0.0-m0", + "private": true, + "type": "module", + "description": "JPW7-compatible web jianpu editor baseline", + "scripts": { + "build": "tsc -p tsconfig.json && node scripts/copy-static.mjs", + "test": "npm run build && node scripts/run-m1-corpus.mjs && node scripts/run-m3-probe.mjs && node --test test/m0.test.mjs test/m1.test.mjs test/m2.test.mjs test/m3.test.mjs test/m5.test.mjs test/m6.test.mjs test/m7.test.mjs test/m8.test.mjs", + "m9:regression": "npm run build && node scripts/m9-regression.mjs", + "check": "npm test", + "dev": "npm run build && node scripts/serve.mjs", + "fixtures:manifest": "node scripts/generate-fixture-manifest.mjs", + "baseline:check": "node scripts/m0-check.mjs", + "m1:corpus": "npm run build && node scripts/run-m1-corpus.mjs", + "m3:probe": "npm run build && node scripts/run-m3-probe.mjs" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/schema/jianpu.schema.json b/schema/jianpu.schema.json new file mode 100644 index 0000000..f35f1ba --- /dev/null +++ b/schema/jianpu.schema.json @@ -0,0 +1,283 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://jianpu.local/schema/jianpu.schema.json", + "title": "JPW7 Web Editor Project", + "type": "object", + "additionalProperties": false, + "required": ["format", "schemaVersion", "projectId", "source", "score"], + "properties": { + "format": { "const": "jianpu-web" }, + "schemaVersion": { "const": 1 }, + "projectId": { "type": "string", "minLength": 1 }, + "source": { "anyOf": [{ "$ref": "#/$defs/source" }, { "type": "null" }] }, + "score": { "$ref": "#/$defs/score" } + }, + "$defs": { + "range": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["start", "end", "line", "column", "endLine", "endColumn", "byteStart", "byteEnd"], + "properties": { + "start": { "type": "integer", "minimum": 0 }, + "end": { "type": "integer", "minimum": 0 }, + "line": { "type": "integer", "minimum": 1 }, + "column": { "type": "integer", "minimum": 1 }, + "endLine": { "type": "integer", "minimum": 1 }, + "endColumn": { "type": "integer", "minimum": 1 }, + "byteStart": { "type": "integer", "minimum": 0 }, + "byteEnd": { "type": "integer", "minimum": 0 } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["fileName", "encoding", "bom", "sha256", "rawBase64"], + "properties": { + "fileName": { "type": ["string", "null"] }, + "encoding": { "enum": ["utf-16le", "utf-16be", "utf-8", "unknown"] }, + "bom": { "enum": ["utf-16le", "utf-16be", "utf-8", "none"] }, + "sha256": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$" }, + "rawBase64": { "type": ["string", "null"] } + } + }, + "rational": { + "type": "object", + "additionalProperties": false, + "required": ["numerator", "denominator"], + "properties": { + "numerator": { "type": "integer" }, + "denominator": { "type": "integer", "not": { "const": 0 } } + } + }, + "pitch": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["degree", "accidental", "octave"], + "properties": { + "degree": { "type": ["integer", "null"] }, + "accidental": { "enum": [-1, 0, 1] }, + "octave": { "type": ["integer", "null"] } + } + }, + "binding": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["range", "raw"], + "properties": { + "range": { "$ref": "#/$defs/range" }, + "raw": { "type": ["string", "null"] } + } + }, + "decoration": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "raw", "playback", "sourceRange"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "type": "string" }, + "raw": { "type": "string" }, + "playback": { "enum": ["w1", "visual", "unknown"] }, + "sourceRange": { "$ref": "#/$defs/range" } + } + }, + "tuplet": { + "type": "object", + "additionalProperties": false, + "required": ["id", "ratio", "ordinal", "raw"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "ratio": { "type": "integer", "minimum": 2 }, + "ordinal": { "type": "integer", "minimum": 0 }, + "raw": { "type": "string" } + } + }, + "privateGlyph": { + "type": "object", + "additionalProperties": false, + "required": ["fontRole", "codePoint", "raw"], + "properties": { + "fontRole": { "type": ["string", "null"] }, + "codePoint": { "type": ["integer", "null"] }, + "raw": { "type": "string" } + } + }, + "event": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "pitch", "duration", "text", "source"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["note", "rest", "barline", "text", "unknown"] }, + "pitch": { "$ref": "#/$defs/pitch" }, + "duration": { "anyOf": [{ "$ref": "#/$defs/rational" }, { "type": "null" }] }, + "text": { "type": ["string", "null"] }, + "source": { "$ref": "#/$defs/binding" }, + "decorations": { "type": "array", "items": { "$ref": "#/$defs/decoration" } }, + "chord": { "type": "array", "items": { "$ref": "#/$defs/pitch" } }, + "hidden": { "type": "boolean" }, + "lyricSkip": { "type": "boolean" }, + "tuplet": { "anyOf": [{ "$ref": "#/$defs/tuplet" }, { "type": "null" }] }, + "privateGlyph": { "anyOf": [{ "$ref": "#/$defs/privateGlyph" }, { "type": "null" }] }, + "velocity": { "type": "number", "minimum": 0, "maximum": 127 }, + "gateRatio": { "anyOf": [{ "$ref": "#/$defs/rational" }, { "type": "null" }] } + } + }, + "measure": { + "type": "object", + "additionalProperties": false, + "required": ["id", "ordinal", "beats", "events"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "ordinal": { "type": "integer", "minimum": 0 }, + "beats": { "$ref": "#/$defs/rational" }, + "events": { "type": "array", "items": { "$ref": "#/$defs/event" } }, + "barlineBefore": { "enum": ["|", "||", "|:", ":|", "::", "dashed", "unknown"] }, + "barlineAfter": { "enum": ["|", "||", "|:", ":|", "::", "dashed", "unknown"] }, + "repeatStart": { "type": "boolean" }, + "repeatEnd": { "type": "boolean" }, + "ending": { "type": ["integer", "null"], "minimum": 1 }, + "tempoBpm": { "type": ["number", "null"], "minimum": 1 }, + "meter": { "anyOf": [{ "type": "object", "additionalProperties": false, "required": ["numerator", "denominator"], "properties": { "numerator": { "type": "integer", "minimum": 1 }, "denominator": { "type": "integer", "minimum": 1 } } }, { "type": "null" }] }, + "key": { "type": ["string", "null"] } + } + }, + "voice": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "sectionId", "visible", "measures"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "sectionId": { "type": ["string", "null"] }, + "visible": { "type": "boolean" }, + "measures": { "type": "array", "items": { "$ref": "#/$defs/measure" } }, + "temporary": { "type": "boolean" }, + "alignment": { "enum": ["aligned", "independent", "overlay"] }, + "fontRole": { "type": ["string", "null"] } + } + }, + "section": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "known", "sourceRange"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "name": { "type": "string" }, + "known": { "type": "boolean" }, + "sourceRange": { "$ref": "#/$defs/range" } + } + }, + "anchor": { + "type": "object", + "additionalProperties": false, + "required": ["raw", "startId", "endId"], + "properties": { + "raw": { "type": ["string", "null"] }, + "startId": { "type": ["string", "null"] }, + "endId": { "type": ["string", "null"] } + } + }, + "lyric": { + "type": "object", + "additionalProperties": false, + "required": ["verseStart", "verseEnd", "prefix", "lines", "syllables"], + "properties": { + "verseStart": { "type": ["integer", "null"] }, + "verseEnd": { "type": ["integer", "null"] }, + "prefix": { "type": ["string", "null"] }, + "lines": { "type": "array", "items": { "type": "string" } }, + "syllables": { "type": "array", "items": { "type": "string" } }, + "syllableEventIds": { "type": "array", "items": { "type": ["string", "null"] } } + } + }, + "attachment": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "raw", "anchor", "status", "sourceRange"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "type": "string" }, + "raw": { "type": "string" }, + "anchor": { "$ref": "#/$defs/anchor" }, + "status": { "enum": ["attached", "orphaned", "unresolved"] }, + "sourceRange": { "$ref": "#/$defs/range" }, + "lyric": { "$ref": "#/$defs/lyric" }, + "visible": { "type": "boolean" }, + "privateGlyph": { "anyOf": [{ "$ref": "#/$defs/privateGlyph" }, { "type": "null" }] } + } + }, + "pageObject": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "raw", "sourceRange"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "type": "string" }, + "raw": { "type": "string" }, + "sourceRange": { "$ref": "#/$defs/range" }, + "position": { "anyOf": [{ "type": "object", "additionalProperties": false, "required": ["x", "y"], "properties": { "x": { "type": "number" }, "y": { "type": "number" } } }, { "type": "null" }] }, + "visible": { "type": "boolean" } + } + }, + "unknownBlock": { + "type": "object", + "additionalProperties": false, + "required": ["id", "reason", "rawText", "context", "sourceRange"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "reason": { "type": "string" }, + "rawText": { "type": "string" }, + "context": { "type": "string" }, + "sourceRange": { "$ref": "#/$defs/range" } + } + }, + "title": { + "type": "object", + "additionalProperties": false, + "required": ["title", "composer", "lyricist", "key", "meter", "tempo", "expression"], + "properties": { + "title": { "type": "string" }, + "composer": { "type": "string" }, + "lyricist": { "type": "string" }, + "key": { "type": "string" }, + "meter": { "type": "string" }, + "tempo": { "type": ["number", "null"] }, + "expression": { "type": "string" } + } + }, + "manualLayout": { + "type": "object", + "additionalProperties": false, + "required": ["lockedMeasureIds", "forcedBreakAfterMeasureIds", "measureSpacing", "objectPositions"], + "properties": { + "lockedMeasureIds": { "type": "array", "items": { "type": "string" } }, + "forcedBreakAfterMeasureIds": { "type": "array", "items": { "type": "string" } }, + "measureSpacing": { "type": "object", "additionalProperties": { "type": "number" } }, + "objectPositions": { "type": "object", "additionalProperties": { "type": "object", "required": ["x", "y"], "properties": { "x": { "type": "number" }, "y": { "type": "number" } } } }, + "measurePositions": { "type": "object", "additionalProperties": { "type": "object", "required": ["x", "y"], "properties": { "x": { "type": "number" }, "y": { "type": "number" } } } }, + "voiceOrder": { "type": "array", "items": { "type": "string" } }, + "voiceSpacing": { "type": "object", "additionalProperties": { "type": "number" } }, + "voiceAlignment": { "type": "object", "additionalProperties": { "enum": ["aligned", "independent", "overlay"] } }, + "lyricOffsets": { "type": "object", "additionalProperties": { "type": "object", "required": ["x", "y"], "properties": { "x": { "type": "number" }, "y": { "type": "number" } } } }, + "fontOverrides": { "type": "object", "additionalProperties": { "type": "object", "properties": { "role": { "type": "string" }, "sizePx": { "type": "number" }, "scaleX": { "type": "number" }, "scaleY": { "type": "number" } } } } + } + }, + "score": { + "type": "object", + "additionalProperties": false, + "required": ["title", "sections", "voices", "attachments", "pageObjects", "unknownBlocks", "documentOptions", "fontRoles", "manualLayout", "playbackPolicyVersion"], + "properties": { + "title": { "$ref": "#/$defs/title" }, + "sections": { "type": "array", "items": { "$ref": "#/$defs/section" } }, + "voices": { "type": "array", "items": { "$ref": "#/$defs/voice" } }, + "attachments": { "type": "array", "items": { "$ref": "#/$defs/attachment" } }, + "pageObjects": { "type": "array", "items": { "$ref": "#/$defs/pageObject" } }, + "unknownBlocks": { "type": "array", "items": { "$ref": "#/$defs/unknownBlock" } }, + "documentOptions": { "type": "object", "additionalProperties": { "type": "string" } }, + "fontRoles": { "type": "object", "additionalProperties": { "type": "string" } }, + "manualLayout": { "$ref": "#/$defs/manualLayout" }, + "playbackPolicyVersion": { "const": 1 } + } + } + } +} diff --git a/scripts/copy-static.mjs b/scripts/copy-static.mjs new file mode 100644 index 0000000..5f4ce00 --- /dev/null +++ b/scripts/copy-static.mjs @@ -0,0 +1,5 @@ +import { cp, mkdir } from "node:fs/promises"; + +await mkdir("dist", { recursive: true }); +await cp("index.html", "dist/index.html"); +await cp("src/app/styles.css", "dist/styles.css"); diff --git a/scripts/generate-fixture-manifest.mjs b/scripts/generate-fixture-manifest.mjs new file mode 100644 index 0000000..6655253 --- /dev/null +++ b/scripts/generate-fixture-manifest.mjs @@ -0,0 +1,82 @@ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { basename, join, relative } from "node:path"; + +const rawRoot = "research/jpw7/demo"; +const decodedRoot = "research/jpw7/decoded"; +const manifestPath = "fixtures/manifest.json"; + +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const sorted = (values) => [...values].sort((left, right) => left.localeCompare(right, "zh-Hans-CN")); +const walk = async (root, prefix = "") => { + const entries = await readdir(join(root, prefix), { withFileTypes: true }); + const paths = []; + for (const entry of entries) { + const child = join(prefix, entry.name); + if (entry.isDirectory()) paths.push(...await walk(root, child)); + else paths.push(child); + } + return paths; +}; + +const rawPaths = sorted((await walk(rawRoot)).filter((path) => path.toLowerCase().endsWith(".jpwabc"))); +const samples = []; +for (const rawRelativePath of rawPaths) { + const rawName = basename(rawRelativePath); + const candidates = [ + `${rawName}.txt`, + `${rawName.replace(/\.jpwabc$/i, "")}.txt`, + `${rawRelativePath}.txt`, + rawName + ]; + let decodedName; + for (const candidate of candidates) { + try { + await readFile(join(decodedRoot, candidate)); + decodedName = candidate; + break; + } catch { + // Try the next conventional decoded filename. + } + } + if (!decodedName) { + throw new Error(`No decoded pair found for ${rawName}`); + } + + const rawPath = join(rawRoot, rawRelativePath); + const decodedPath = join(decodedRoot, decodedName); + const rawBytes = await readFile(rawPath); + const decodedBytes = await readFile(decodedPath); + samples.push({ + id: rawRelativePath.replace(/\.jpwabc$/i, "").replaceAll("/", "::"), + sourceReadOnly: true, + rawPath: relative(".", rawPath), + decodedPath: relative(".", decodedPath), + rawBytes: rawBytes.byteLength, + decodedBytes: decodedBytes.byteLength, + rawSha256: sha256(rawBytes), + decodedSha256: sha256(decodedBytes), + classification: { + status: "pending", + firstResponsibleMilestone: "M1", + expected: [ + "encoding-and-bom", + "section-inventory", + "core-semantics", + "unknown-content-retained", + "diagnostics-emitted" + ] + } + }); +} + +const manifest = { + manifestVersion: 1, + purpose: "M0 read-only fixture baseline", + sampleCount: samples.length, + generatedBy: "scripts/generate-fixture-manifest.mjs", + sourceRoots: { raw: rawRoot, decoded: decodedRoot }, + samples +}; +await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); +console.log(`Wrote ${manifestPath} with ${samples.length} sample pairs`); diff --git a/scripts/generate-requirements.mjs b/scripts/generate-requirements.mjs new file mode 100644 index 0000000..55219ae --- /dev/null +++ b/scripts/generate-requirements.mjs @@ -0,0 +1,43 @@ +import { mkdir, writeFile } from "node:fs/promises"; + +const range = (prefix, start, end) => Array.from({ length: end - start + 1 }, (_, index) => `${prefix}-${String(start + index).padStart(3, "0")}`); +const rows = []; +const add = ({ ids, priority, stages, accRefs, source, scope = "R1", notes }) => { + for (const id of ids) rows.push({ id, source, priority, scope, stages, accRefs, status: "baseline-not-started", notes }); +}; + +add({ ids: range("F", 1, 7), source: "PRD-F-001..007", priority: "P0", stages: ["M1", "M2", "M4", "M5"], accRefs: ["ACC-F-001..020", "ACC-X-001..012"], notes: "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" }); +add({ ids: ["F-008"], source: "PRD-F-008", priority: "P1", stages: ["M2", "M4", "M6", "M7"], accRefs: ["ACC-F-021..022", "ACC-L-009..011", "ACC-L-019", "ACC-X-013"], notes: "扩展编辑能力,未知内容必须保留并诊断。" }); +add({ ids: range("E", 1, 4), source: "PRD-E-001..004", priority: "P0", stages: ["M2", "M4", "M6"], accRefs: ["ACC-E-001..004", "ACC-E-007", "ACC-F-009"], notes: "核心节拍、音符、休止和时值语义。" }); +add({ ids: range("E", 5, 8), source: "PRD-E-005..008", priority: "P1", stages: ["M6", "M8"], accRefs: ["ACC-E-005..012", "ACC-O-001..006", "ACC-A-009", "ACC-A-017", "ACC-A-021..023"], notes: "复杂装饰、和弦/伴奏及扩展语义。" }); +add({ ids: range("T", 1, 6), source: "PRD-T-001..006", priority: "P0/P1", stages: ["M4", "M6"], accRefs: ["ACC-T-001..007", "ACC-L-006", "ACC-L-012"], notes: "歌词、分词、对齐和未知歌词内容保留。" }); +add({ ids: range("V", 1, 6), source: "PRD-V-001..006", priority: "P1", stages: ["M7"], accRefs: ["ACC-V-001..006", "ACC-O-003..006", "ACC-L-020"], notes: "多声部、声部组和临时声部。" }); +add({ ids: ["V-007"], source: "PRD-V-007", priority: "P1", stages: ["M5", "M7"], accRefs: ["ACC-V-007", "ACC-A-015"], notes: "声部级播放/静音与导出边界。" }); +add({ ids: range("L", 1, 6), source: "PRD-L-001..006", priority: "P0/P1", stages: ["M3", "M6"], accRefs: ["ACC-L-001..008", "ACC-L-013..018", "ACC-L-020", "ACC-G-001..004", "ACC-X-009"], notes: "A4 固定布局、分页、字体测量和 SVG 正式谱面。" }); +add({ ids: range("L", 7, 8), source: "PRD-L-007..008", priority: "P1", stages: ["M7"], accRefs: ["ACC-L-009..011", "ACC-L-019"], notes: "手动布局状态必须进入工程保存。" }); +add({ ids: ["L-009"], source: "PRD-L-009", priority: "P1", stages: ["M3", "M6"], accRefs: ["ACC-L-005", "ACC-L-015", "ACC-G-001..004"], notes: "复杂布局附件和占位行为。" }); +add({ ids: ["L-010"], source: "PRD-L-010", priority: "P1", stages: ["M3"], accRefs: ["ACC-L-018", "ACC-Q-002"], notes: "输出确定性和溢出诊断。" }); +add({ ids: [...range("A", 1, 3), ...range("A", 6, 7)], source: "PRD-A-001..003,006..007", priority: "P0", stages: ["M5"], accRefs: ["ACC-A-001..008", "ACC-A-012..016"], notes: "基础播放、控制、时间线和声部控制。" }); +add({ ids: range("A", 4, 5), source: "PRD-A-004..005", priority: "P1", stages: ["M8"], accRefs: ["ACC-A-009..011", "ACC-A-017..023"], notes: "反复、谱内控制状态和扩展播放降级。" }); +add({ ids: ["A-008"], source: "PRD-A-008", priority: "P2", scope: "out-of-scope", stages: [], accRefs: ["ACC-A-023"], notes: "外部音色/扩展音频能力明确不纳入本目标。" }); +add({ ids: ["A-009"], source: "PRD-A-009", priority: "P1", stages: ["M8"], accRefs: ["ACC-A-006", "ACC-A-018", "ACC-A-022", "ACC-E-007", "ACC-T-007"], notes: "W1 播放金样和控制语义。" }); +add({ ids: range("U", 1, 4), source: "PRD-U-001..004", priority: "P0", stages: ["M4", "M5", "M9"], accRefs: ["ACC-Q-004..006", "ACC-L-013", "ACC-X-003", "ACC-X-006"], notes: "可用性、键盘、无障碍和错误提示。" }); +add({ ids: range("Q", 1, 9), source: "验收标准-质量门槛", priority: "P0/P1", stages: ["M0", "M5", "M9"], accRefs: ["ACC-Q-001..009", "ACC-X-001..014"], notes: "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" }); + +await mkdir("config", { recursive: true }); +const baseline = { + version: 1, + generatedBy: "scripts/generate-requirements.mjs", + sources: [ + { path: "docs/JPW7在线简谱编辑器-产品需求文档.md", version: "current project baseline" }, + { path: "docs/JPW7在线简谱编辑器-功能规格与技术方案.md", version: "current project baseline" }, + { path: "docs/JPW7在线简谱编辑器-开发步骤计划.md", version: "v1.1" }, + { path: "docs/JPW7在线简谱编辑器-验收标准.md", version: "v1.2" }, + { path: "docs/JPW7在线简谱编辑器-兼容性矩阵.md", version: "current project baseline" } + ], + capabilityDimensions: ["file-codec", "parser", "lossless-retention", "semantic-model", "stable-anchor", "commands", "project-save", "layout", "svg", "playback", "compatibility-export", "e2e", "performance", "security", "accessibility", "licensing", "evidence"], + entryCount: rows.length, + entries: rows +}; +await writeFile("config/requirements.json", `${JSON.stringify(baseline, null, 2)}\n`, "utf8"); +console.log(`Wrote config/requirements.json with ${rows.length} traceability entries`); diff --git a/scripts/m0-check.mjs b/scripts/m0-check.mjs new file mode 100644 index 0000000..36f9e4a --- /dev/null +++ b/scripts/m0-check.mjs @@ -0,0 +1,107 @@ +import { access, readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +const failures = []; +const check = (condition, message) => { + if (!condition) failures.push(message); +}; +const loadJson = async (path) => JSON.parse(await readFile(path, "utf8")); +const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const exists = async (path) => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +const packageJson = await loadJson("package.json"); +for (const script of ["build", "test", "check", "dev", "fixtures:manifest", "baseline:check"]) { + check(typeof packageJson.scripts?.[script] === "string", `package.json is missing script: ${script}`); +} +check(packageJson.type === "module", "package.json must use native ESM"); +check(packageJson.devDependencies?.typescript === "5.9.3", "TypeScript version must remain pinned at 5.9.3 for M0"); + +const manifest = await loadJson("fixtures/manifest.json"); +check(manifest.manifestVersion === 1, "fixture manifest version must be 1"); +check(manifest.sampleCount === 33, `fixture manifest must contain 33 pairs, got ${manifest.sampleCount}`); +check(manifest.samples?.length === 33, "fixture manifest sample array must contain 33 entries"); +const ids = new Set(); +for (const sample of manifest.samples ?? []) { + check(!ids.has(sample.id), `duplicate fixture id: ${sample.id}`); + ids.add(sample.id); + check(sample.sourceReadOnly === true, `fixture is not read-only: ${sample.id}`); + check(sample.rawPath.startsWith("research/"), `raw fixture escapes research root: ${sample.id}`); + check(sample.decodedPath.startsWith("research/"), `decoded fixture escapes research root: ${sample.id}`); + check(sample.rawPath !== sample.decodedPath, `raw/decoded fixture paths must differ: ${sample.id}`); + const rawBytes = await readFile(sample.rawPath); + const decodedBytes = await readFile(sample.decodedPath); + check(rawBytes.byteLength === sample.rawBytes, `raw byte count drifted: ${sample.id}`); + check(decodedBytes.byteLength === sample.decodedBytes, `decoded byte count drifted: ${sample.id}`); + check(sha256(rawBytes) === sample.rawSha256, `raw SHA-256 drifted: ${sample.id}`); + check(sha256(decodedBytes) === sample.decodedSha256, `decoded SHA-256 drifted: ${sample.id}`); +} + +const researchBaseline = await loadJson("config/research-baseline.json"); +check(researchBaseline.readOnly === true, "research baseline must be read-only"); +check(researchBaseline.observedInventory.samplePairCount === manifest.sampleCount, "research and fixture sample counts differ"); +check(researchBaseline.observedInventory.majorSectionKinds === 8, "M0 section-kind baseline must remain 8"); +check(researchBaseline.observedInventory.braceTokenHeadKinds === 214, "M0 token-head baseline must remain 214"); +for (const sourcePath of Object.values(researchBaseline.source)) check(await exists(sourcePath), `missing research baseline source: ${sourcePath}`); + +const schema = await loadJson("schema/jianpu.schema.json"); +check(schema.$schema?.includes("2020-12"), "project schema must declare JSON Schema 2020-12"); +check(schema.properties?.format?.const === "jianpu-web", "project schema format must be jianpu-web"); +check(schema.properties?.schemaVersion?.const === 1, "project schema version must be 1"); +for (const definition of ["source", "score", "section", "measure", "event", "unknownBlock", "manualLayout"]) { + check(Boolean(schema.$defs?.[definition]), `project schema missing definition: ${definition}`); +} + +const requirements = await loadJson("config/requirements.json"); +check(requirements.entryCount === requirements.entries?.length, "requirements entryCount is not reproducible"); +const requirementIds = new Set((requirements.entries ?? []).map((entry) => entry.id)); +for (const requiredId of ["F-001", "E-001", "T-001", "V-001", "L-001", "A-001", "U-001", "Q-001", "A-008"]) { + check(requirementIds.has(requiredId), `requirements traceability is missing ${requiredId}`); +} +check(requirementIds.size === requirements.entries?.length, "requirements IDs must be unique"); +check(requirements.entries.every((entry) => Array.isArray(entry.stages) && Array.isArray(entry.accRefs)), "requirements entries need stage and ACC references"); +check(requirements.entries.find((entry) => entry.id === "A-008")?.scope === "out-of-scope", "P2 A-008 must remain explicitly out of scope"); +check(Array.isArray(requirements.sources) && requirements.sources.length >= 5, "requirements source documents are incomplete"); +for (const source of requirements.sources ?? []) check(await exists(source.path), `missing requirements source document: ${source.path}`); +check(Array.isArray(requirements.capabilityDimensions) && requirements.capabilityDimensions.includes("evidence"), "requirements capability dimensions are incomplete"); + +const licenses = await loadJson("config/resource-licenses.json"); +check(Array.isArray(licenses.entries) && licenses.entries.length >= 4, "resource license list is incomplete"); +check(licenses.entries.every((entry) => entry.source && entry.license && entry.status), "resource license entries need source, license and status"); +check(licenses.entries.find((entry) => entry.id === "research.jpw7-private-assets")?.distribution === "forbidden", "private research assets must be forbidden for distribution"); + +const writeSet = await loadJson("config/original-format-write-set.json"); +check(writeSet.policy === "allow-list-only", "original-format writer must be allow-list-only"); +check(writeSet.defaultWrite === "blocked", "original-format writer default must be blocked"); +check(writeSet.externalReplayRequired === true, "original-format external replay gate is required"); +check(writeSet.unknownContent === "preserve-and-block-unsafe-export", "unknown original content policy is unsafe-export blocking"); +check(Array.isArray(writeSet.entries) && writeSet.entries.length >= 5, "original-format write set is incomplete"); + +const environment = await loadJson("config/acceptance-environment.json"); +check(environment.baselineId === "jpw7-web-m0", "acceptance environment baseline ID drifted"); +check(environment.primaryAcceptanceProfile.machine === "Windows 11 x64 desktop", "primary acceptance machine must be fixed"); +check(environment.primaryAcceptanceProfile.browserProfiles.length === 3, "acceptance browser profile list must contain three profiles"); +check(environment.primaryAcceptanceProfile.paperProfile.size === "A4", "acceptance paper must be A4"); +check(environment.primaryAcceptanceProfile.paperProfile.printScale === "100%", "acceptance print scale must be 100%"); +check(environment.status.includes("measured run evidence"), "acceptance environment must require measured run evidence"); + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures }, null, 2)); + process.exitCode = 1; +} else { + console.log(JSON.stringify({ + ok: true, + check: "M0 baseline", + samplePairs: manifest.sampleCount, + requirementEntries: requirements.entryCount, + schemaVersion: schema.properties.schemaVersion.const, + originalFormatPolicy: writeSet.policy, + acceptanceProfile: environment.baselineId + }, null, 2)); +} diff --git a/scripts/m9-regression.mjs b/scripts/m9-regression.mjs new file mode 100644 index 0000000..c6c3625 --- /dev/null +++ b/scripts/m9-regression.mjs @@ -0,0 +1,131 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { performance } from "node:perf_hooks"; +import os from "node:os"; +import { parseJpwabc } from "../dist/domain/jpwabc.js"; +import { decodeProject, encodeProject } from "../dist/domain/project-codec.js"; +import { modelFromParse, createEmptyDocument, createNoteEvent, rational, modelEquivalent } from "../dist/domain/score-model.js"; +import { compilePlaybackTimeline } from "../dist/playback/playback.js"; +import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../dist/layout/layout-engine.js"; +import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js"; +import { exportSnapshotSvg, buildPrintableHtml, outputGeometry } from "../dist/output/output.js"; +import { probeSvg, renderSnapshotSvg } from "../dist/layout/svg-renderer.js"; + +const ROOT = "acceptance/m9"; +const manifest = JSON.parse(await readFile("fixtures/manifest.json", "utf8")); +const environment = { + generatedAt: new Date().toISOString(), + node: process.version, + platform: `${process.platform} ${os.release()} ${os.arch()}`, + cpu: os.cpus()[0]?.model ?? "unknown", + memoryBytes: os.totalmem(), + chrome: (() => { try { return execFileSync("google-chrome", ["--version"], { encoding: "utf8" }).trim(); } catch { return "not available"; } })(), + firefox: (() => { try { return execFileSync("firefox", ["--version"], { encoding: "utf8" }).trim(); } catch { return "not available"; } })(), + project: "jianpu-web-editor", + buildCommand: "npm run build", + testCommand: "npm test", + note: "Node-side local evidence; browser, original JPW7, printer and external resource claims are listed separately and are not inferred." +}; + +for (const directory of [ROOT, `${ROOT}/projects`, `${ROOT}/svg`, `${ROOT}/png`, `${ROOT}/pdf`, `${ROOT}/screenshots`, `${ROOT}/native-roundtrip`]) await mkdir(directory, { recursive: true }); +await writeFile(`${ROOT}/environment.json`, `${JSON.stringify(environment, null, 2)}\n`); + +const importRows = []; +const layoutRows = []; +const playbackRows = []; +const exportRows = []; +const caseRows = []; +const fontProvider = createDefaultFontMeasurementProvider(); +let firstSnapshot = null; +let maxPages = 0; +let preservedCount = 0; +let reopenCount = 0; +let svgCount = 0; +let blockedExportCount = 0; + +for (const [index, fixture] of manifest.samples.entries()) { + const bytes = await readFile(fixture.rawPath); + const parsed = parseJpwabc(new Uint8Array(bytes)); + const model = modelFromParse(parsed, { fileName: basename(fixture.rawPath) }); + const projectText = encodeProject(model); + const reopened = decodeProject(projectText); + const reopenedEquivalent = modelEquivalent(model, reopened); + if (reopenedEquivalent) reopenCount += 1; + if (model.source?.rawBase64) preservedCount += 1; + const snapshot = await layoutDocument(model, DEFAULT_PAPER_CONFIG, fontProvider); + if (!firstSnapshot) firstSnapshot = snapshot; + const summary = layoutSummary(snapshot); + maxPages = Math.max(maxPages, summary.pageCount); + const timeline = compilePlaybackTimeline(model); + const safeName = `${String(index + 1).padStart(2, "0")}-${fixture.id.replace(/[^\p{L}\p{N}._-]+/gu, "_")}`; + await writeFile(`${ROOT}/projects/${safeName}.jianpu.json`, projectText); + const svgs = exportSnapshotSvg(snapshot); + for (const [pageIndex, svg] of svgs.entries()) { + await writeFile(`${ROOT}/svg/${safeName}-page-${pageIndex + 1}.svg`, svg); + svgCount += 1; + } + const probe = svgs[0] ? probeSvg(svgs[0]) : null; + importRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, rawPath: fixture.rawPath, parsedStatus: parsed.compatibility.status, encoding: parsed.source.encoding, sections: parsed.sections.length, diagnostics: parsed.diagnostics.length, unknownTokens: parsed.unknownTokens.length, unknownBlocks: parsed.unknownBlocks.length, sourcePreserved: Boolean(model.source?.rawBase64), result: "pass-local" }); + layoutRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, summary, deterministicKey: snapshot.deterministicKey, svgProbe: probe, warnings: snapshot.warnings.length, result: "pass-local" }); + playbackRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, voiceCount: timeline.voiceIds.length, eventCount: timeline.events.length, durationTicks: timeline.durationTicks, warnings: timeline.warnings, controlEventCount: timeline.controlEvents?.length ?? 0, result: "pass-local-semantic" }); + const exportStatus = model.attachments.length || model.pageObjects.length || model.unknownBlocks.length || model.voices.some((voice) => voice.temporary) ? "blocked-by-safety-preflight" : "core-preflight-evaluated"; + if (exportStatus === "blocked-by-safety-preflight") blockedExportCount += 1; + exportRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, svgPages: svgs.length, outputGeometry: outputGeometry(snapshot), svgIndependentArtifact: `${ROOT}/svg/${safeName}-page-1.svg`, printableHtmlBytes: buildPrintableHtml(snapshot).length, png: "not-executed-in-node; representative Chromium PNG remains in acceptance/m5", pdf: "not-executed-in-node; representative Chromium PDF remains in acceptance/m5", compatibilityPreflight: exportStatus, result: "pass-local-svg-and-engineering-export" }); + caseRows.push({ caseId: `M9-33-${String(index + 1).padStart(2, "0")}`, fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, environment: "node-local", steps: "import -> model -> project save/reopen -> fixed layout -> timeline -> SVG", expected: "source retained, complete project round-trip, deterministic A4 SVG and bounded playback diagnostics", actual: { reopenedEquivalent, pages: summary.pageCount, lines: summary.lineCount, measures: summary.measureCount, symbols: summary.symbolCount, playbackEvents: timeline.events.length }, result: reopenedEquivalent && svgs.length > 0 ? "pass-local" : "failed", evidence: [`${ROOT}/projects/${safeName}.jianpu.json`, `${ROOT}/svg/${safeName}-page-1.svg`] }); +} + +const malicious = `JPW-ABC File Ver 7.30\n.Voice\n 1\n`; +const maliciousModel = modelFromParse(parseJpwabc(new TextEncoder().encode(malicious)), { fileName: "malicious-input.jpwabc" }); +const maliciousSnapshot = await layoutDocument(maliciousModel, DEFAULT_PAPER_CONFIG, fontProvider); +const maliciousSvg = renderSnapshotSvg(maliciousSnapshot)[0]; +const unsafeTag = /<[^>]*(?:onload|onclick|javascript:)[^>]*>/i.test(maliciousSvg); +const securityRow = { caseId: "ACC-Q-008", input: "malicious source text", expected: "data only; no executable SVG/HTML attributes or external load", actual: { unsafeTag, escapedText: maliciousSvg.includes("<svg") }, result: !unsafeTag && maliciousSvg.includes("<svg") ? "pass-local" : "failed", evidence: `${ROOT}/security-probe.json` }; +await writeFile(`${ROOT}/security-probe.json`, `${JSON.stringify(securityRow, null, 2)}\n`); +caseRows.push(securityRow); + +const performanceSamples = []; +const performanceFixture = manifest.samples[0]; +const performanceBytes = await readFile(performanceFixture.rawPath); +for (let run = 0; run < 20; run += 1) { + const started = performance.now(); + const parsed = parseJpwabc(new Uint8Array(performanceBytes)); + const model = modelFromParse(parsed); + const snapshot = await layoutDocument(model, DEFAULT_PAPER_CONFIG, fontProvider); + performanceSamples.push({ run: run + 1, milliseconds: performance.now() - started, pages: snapshot.pages.length }); +} +const sortedTimes = performanceSamples.map((sample) => sample.milliseconds).sort((a, b) => a - b); +const p95 = sortedTimes[Math.min(sortedTimes.length - 1, Math.ceil(sortedTimes.length * 0.95) - 1)]; +const longDocument = createEmptyDocument("m9-10-page-synthetic"); +const longMeasures = []; +for (let index = 0; index < 120; index += 1) longMeasures.push({ id: `m9-measure-${index}`, ordinal: index, beats: rational(4), events: [1, 2, 3, 4].map((degree, eventIndex) => createNoteEvent(`m9-event-${index}-${eventIndex}`, degree, rational(1))) }); +longDocument.voices.push({ id: "m9-voice", name: "性能合成", sectionId: null, visible: true, measures: longMeasures }); +const longStarted = performance.now(); +const longSnapshot = await layoutDocument(longDocument, DEFAULT_PAPER_CONFIG, fontProvider); +const longMilliseconds = performance.now() - longStarted; +await writeFile(`${ROOT}/performance.json`, `${JSON.stringify({ method: "Node local approximation; does not replace 20 cold browser runs, 100-edit P95, 10-minute audio or 30-minute resource-release tests", firstFixture: performanceFixture.id, firstDisplayRuns: performanceSamples, p95Milliseconds: p95, tenPageSynthetic: { pages: longSnapshot.pages.length, milliseconds: longMilliseconds }, targets: { firstDisplayP95Milliseconds: 2000, editP95Milliseconds: 100, tenMinutePlaybackDriftMilliseconds: 20 }, status: "local-measured; browser/device portions not-executed" }, null, 2)}\n`); + +await writeFile(`${ROOT}/import-report.jsonl`, `${importRows.map((row) => JSON.stringify(row)).join("\n")}\n`); +await writeFile(`${ROOT}/layout-report.jsonl`, `${layoutRows.map((row) => JSON.stringify(row)).join("\n")}\n`); +await writeFile(`${ROOT}/playback-timeline.jsonl`, `${playbackRows.map((row) => JSON.stringify(row)).join("\n")}\n`); +await writeFile(`${ROOT}/export-report.jsonl`, `${exportRows.map((row) => JSON.stringify(row)).join("\n")}\n`); +await writeFile(`${ROOT}/case-results.jsonl`, `${caseRows.map((row) => JSON.stringify(row)).join("\n")}\n`); +let previousNativeRows = new Map(); +try { + const previousNativeText = await readFile(`${ROOT}/native-roundtrip-report.jsonl`, "utf8"); + previousNativeRows = new Map(previousNativeText.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)).filter((row) => row.result !== "not-executed").map((row) => [row.fixtureId, row])); +} catch { + // A fresh M9 run has no external native evidence to preserve. +} +const nativeRows = manifest.samples.map((fixture) => previousNativeRows.get(fixture.id) ?? ({ caseId: "ACC-X-012", fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, result: "not-executed", reason: "JPW7 v7.30 original program/Windows replay is not available in this Linux Web-only run; Web round-trip is not substituted.", evidence: `${ROOT}/native-roundtrip-report.jsonl` })); +await writeFile(`${ROOT}/native-roundtrip-report.jsonl`, `${nativeRows.map((row) => JSON.stringify(row)).join("\n")}\n`); +let previousExternal = {}; +try { + previousExternal = JSON.parse(await readFile(`${ROOT}/summary.json`, "utf8")).external ?? {}; +} catch { + // A fresh M9 run has no previous external summary. +} +const nativeCount = nativeRows.filter((row) => row.result !== "not-executed").length; +const nativeStatus = nativeCount === 0 ? "not-executed" : nativeCount === manifest.samples.length ? `pass-${nativeCount}-of-${manifest.samples.length}` : `partial-${nativeCount}-of-${manifest.samples.length}`; +await writeFile(`${ROOT}/summary.json`, `${JSON.stringify({ sampleCount: manifest.samples.length, preservedCount, reopenCount, svgCount, maxPages, blockedExportCount, localCases: caseRows.length, security: securityRow.result, external: { nativeRoundtrip: nativeStatus, edge: "not-executed", firefox: "not-executed", printer: "not-executed", tenMinuteAudio: "not-executed", ...previousExternal, nativeRoundtrip: nativeStatus } }, null, 2)}\n`); +console.log(JSON.stringify({ samples: manifest.samples.length, preservedCount, reopenCount, svgCount, maxPages, blockedExportCount, p95Milliseconds: p95, longDocumentPages: longSnapshot.pages.length })); diff --git a/scripts/run-m1-corpus.mjs b/scripts/run-m1-corpus.mjs new file mode 100644 index 0000000..2eaef3e --- /dev/null +++ b/scripts/run-m1-corpus.mjs @@ -0,0 +1,94 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { parseJpwabc, hasExactRoundTrip } from "../dist/domain/jpwabc.js"; + +const manifest = JSON.parse(await readFile("fixtures/manifest.json", "utf8")); +const rows = []; +const failures = []; +for (const fixture of manifest.samples) { + try { + const bytes = await readFile(fixture.rawPath); + const result = parseJpwabc(bytes); + const exactRoundTrip = hasExactRoundTrip(result); + const errors = result.diagnostics.filter((item) => item.severity === "error"); + const warnings = result.diagnostics.filter((item) => item.severity === "warning"); + const status = errors.length ? "recovered-error" : warnings.length || result.unknownTokens.length || result.unknownBlocks.length ? "parsed-with-diagnostics" : "parsed-clean"; + const row = { + id: fixture.id, + rawPath: fixture.rawPath, + decodedPath: fixture.decodedPath, + rawSha256: fixture.rawSha256, + rawBytes: bytes.byteLength, + status, + sourcePreserved: exactRoundTrip, + encoding: result.source.encoding, + bom: result.source.bom, + formatVersion: result.header.formatVersion, + productVersion: result.header.productVersion, + sections: result.sections.map((section) => ({ name: section.name, known: section.known, lineCount: section.lines.length })), + semanticCounts: { + options: result.options.document.length, + unknownOptions: result.options.unknownDocument.length, + fonts: result.fonts.length, + titles: result.titles.length, + voiceLines: result.voices.length, + voiceTokens: result.voices.reduce((count, line) => count + line.tokens.length, 0), + wordBlocks: result.words.length, + attachments: result.attachments.length, + pages: result.pages.length + }, + preservationCounts: { + rawTokens: result.rawTokens.length, + unknownTokens: result.unknownTokens.length, + unknownBlocks: result.unknownBlocks.length + }, + diagnostics: result.diagnostics.map(({ code, severity, message, recovery }) => ({ code, severity, message, recovery })), + compatibility: result.compatibility + }; + rows.push(row); + if (!exactRoundTrip) failures.push(`${fixture.id}: source bytes changed on no-edit serialization`); + } catch (error) { + failures.push(`${fixture.id}: parser threw ${error instanceof Error ? error.message : String(error)}`); + rows.push({ id: fixture.id, rawPath: fixture.rawPath, status: "parser-threw", sourcePreserved: false, error: String(error) }); + } +} + +if (rows.length !== manifest.sampleCount || rows.length !== 33) failures.push(`expected 33 classifications, got ${rows.length}`); +if (rows.some((row) => !row.status || row.sourcePreserved !== true)) failures.push("one or more samples has no status or failed source preservation"); + +const report = { + reportVersion: 1, + milestone: "M1", + purpose: "33-sample parse, preservation and diagnostic classification; not an external JPW7 compatibility pass", + sampleCountExpected: 33, + sampleCountActual: rows.length, + statusCounts: Object.fromEntries([...new Set(rows.map((row) => row.status))].sort().map((status) => [status, rows.filter((row) => row.status === status).length])), + allSourceBytesPreserved: failures.length === 0, + failures, + samples: rows +}; +await mkdir("acceptance/m1", { recursive: true }); +await writeFile("acceptance/m1/sample-classification.json", `${JSON.stringify(report, null, 2)}\n`, "utf8"); +const markdown = [ + "# M1 33 样例分类报告", + "", + "> 分类、诊断和保留结果是 Web parser 的实测结果,不代表 JPW7 原程序兼容通过;外部回验仍由 R0/R1 单独执行。", + "", + `- 样例:${report.sampleCountActual}/${report.sampleCountExpected}`, + `- 无编辑字节级保留:${report.allSourceBytesPreserved ? "是" : "否"}`, + `- 状态:${JSON.stringify(report.statusCounts)}`, + `- 失败:${report.failures.length}`, + "", + "| 样例 | 状态 | 编码/BOM | 区块 | 未知 token | 未知区块 | 诊断 |", + "|---|---|---|---:|---:|---:|---:|", + ...rows.map((row) => `| ${row.id} | ${row.status} | ${row.encoding}/${row.bom} | ${row.sections?.length ?? 0} | ${row.preservationCounts?.unknownTokens ?? "-"} | ${row.preservationCounts?.unknownBlocks ?? "-"} | ${row.diagnostics?.length ?? "-"} |`), + "", + "## 解释边界", + "", + "- `parsed-with-diagnostics` 表示语义/未知内容被保留并有报告,不是全量语法支持声明。", + "- `recovered-error` 表示解析器发现错误但保留了原始字节;安全兼容导出必须阻止。", + "- `sourcePreserved` 仅证明无编辑序列化稳定,不替代 JPW7 原程序回验。", + "" +].join("\n"); +await writeFile("acceptance/m1/sample-classification.md", markdown, "utf8"); +console.log(JSON.stringify({ ok: failures.length === 0, samples: rows.length, statusCounts: report.statusCounts, failures: failures.length }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/scripts/run-m3-probe.mjs b/scripts/run-m3-probe.mjs new file mode 100644 index 0000000..dfce867 --- /dev/null +++ b/scripts/run-m3-probe.mjs @@ -0,0 +1,30 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { parseJpwabc } from "../dist/domain/jpwabc.js"; +import { modelFromParse } from "../dist/domain/score-model.js"; +import { DeterministicFontMeasurementProvider } from "../dist/layout/font-measure.js"; +import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../dist/layout/layout-engine.js"; +import { probeSvg, renderSnapshotSvg } from "../dist/layout/svg-renderer.js"; + +const raw = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc"); +const model = modelFromParse(parseJpwabc(raw), { fileName: "康定情歌.jpwabc" }); +const snapshot = await layoutDocument(model, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider()); +const [svg] = renderSnapshotSvg(snapshot); +const summary = layoutSummary(snapshot); +const probe = probeSvg(svg); +const report = { + reportVersion: 1, + milestone: "M3", + input: "research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc", + expected: { pageCount: 1, lineCount: 6, measureCount: 16, pageWidthMm: 210, pageHeightMm: 297, viewBox: "0 0 21000 29700" }, + actual: { ...summary, pageWidthMm: snapshot.pages[0]?.widthMm ?? null, pageHeightMm: snapshot.pages[0]?.heightMm ?? null, viewBox: probe.viewBox, deterministicKey: snapshot.deterministicKey }, + fontGate: { provider: "DeterministicFontMeasurementProvider", measurements: snapshot.fontMeasurements.length, allLoaded: snapshot.fontMeasurements.every((measurement) => measurement.loaded), fallbackCount: snapshot.fontMeasurements.filter((measurement) => measurement.fallback).length }, + warningsByCode: Object.fromEntries([...new Set(snapshot.warnings.map((warning) => warning.code))].sort().map((code) => [code, snapshot.warnings.filter((warning) => warning.code === code).length])), + svgProbe: probe, + referenceBoundary: "16 measures/1 page/6 lines are the structural baseline; Web symbol count and unknown JPW object geometry are not claimed pixel-equivalent.", + evidencePaths: ["src/layout/layout-engine.ts", "src/layout/font-measure.ts", "src/layout/svg-renderer.ts", "acceptance/m3/kangding-page-1.svg"] +}; +if (summary.pageCount !== 1 || summary.lineCount !== 6 || summary.measureCount !== 16 || probe.viewBox !== "0 0 21000 29700") throw new Error(`M3 baseline mismatch: ${JSON.stringify({ summary, probe })}`); +await mkdir("acceptance/m3", { recursive: true }); +await writeFile("acceptance/m3/kangding-layout-summary.json", `${JSON.stringify(report, null, 2)}\n`, "utf8"); +await writeFile("acceptance/m3/kangding-page-1.svg", svg, "utf8"); +console.log(JSON.stringify({ ok: true, summary, deterministicKey: snapshot.deterministicKey, svg: probe }, null, 2)); diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 0000000..4f2d36a --- /dev/null +++ b/scripts/serve.mjs @@ -0,0 +1,36 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, join, normalize, relative, resolve } from "node:path"; + +const root = resolve("dist"); +const port = Number(process.env.PORT ?? 4173); +const contentTypes = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".css": "text/css; charset=utf-8" +}; + +const server = createServer(async (request, response) => { + const requestPath = decodeURIComponent((request.url ?? "/").split("?", 1)[0]); + const candidate = resolve(join(root, requestPath === "/" ? "index.html" : requestPath)); + const safeRelativePath = relative(root, candidate); + if (safeRelativePath.startsWith("..") || safeRelativePath.includes(`..${process.platform === "win32" ? "\\" : "/"}`)) { + response.writeHead(403).end("Forbidden"); + return; + } + + try { + const file = await stat(candidate); + if (!file.isFile()) throw new Error("not a file"); + response.writeHead(200, { "content-type": contentTypes[extname(candidate)] ?? "application/octet-stream" }); + createReadStream(candidate).pipe(response); + } catch { + response.writeHead(404).end("Not found"); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`JPW7 M0 server listening on http://127.0.0.1:${port}`); +}); diff --git a/src/app/editor.ts b/src/app/editor.ts new file mode 100644 index 0000000..6238307 --- /dev/null +++ b/src/app/editor.ts @@ -0,0 +1,1152 @@ +import { CommandRejectedError, CommandStore, type EditCommand, type EventPatch, type MeasurePatch, type VoiceLayoutPatch } from "../domain/command-store.js"; +import { decodeProject, encodeProject } from "../domain/project-codec.js"; +import { + createEmptyDocument, + createNoteEvent, + createRestEvent, + freshId, + rational, + type ScoreDocument, + type ScoreEvent, + type StableId, + type BarlineStyle, + type PageObjectModel +} from "../domain/score-model.js"; +import { type ParseResult } from "../domain/jpwabc.js"; +import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../layout/layout-engine.js"; +import { createDefaultFontMeasurementProvider } from "../layout/font-measure.js"; +import { probeSvg, renderSnapshotSvg } from "../layout/svg-renderer.js"; +import { CompatibilityExportBlockedError, serializeJpwabc } from "../domain/jpwabc-writer.js"; +import { compilePlaybackTimeline, PlaybackController, type PlaybackTimeline, type PlaybackVoiceControl } from "../playback/playback.js"; +import { exportSnapshotSvg, printSnapshot, renderSnapshotToPng } from "../output/output.js"; +import type { LayoutSnapshot } from "../layout/types.js"; + +interface Selection { + id: StableId; + kind: string; +} + +interface AppPreferences { + previewMode: "standard" | "compact"; +} + +function loadAppPreferences(): AppPreferences { + try { + const value = JSON.parse(globalThis.localStorage?.getItem("jianpu-web:preferences") ?? "null") as Partial | null; + return { previewMode: value?.previewMode === "compact" ? "compact" : "standard" }; + } catch { + return { previewMode: "standard" }; + } +} + +const MAX_JPWABC_IMPORT_BYTES = 512 * 1024; +const MAX_JPWABC_EVENT_TOKENS = 1536; + +function countJpwabcEventTokens(parsed: ParseResult): number { + return parsed.sections.flatMap((section) => section.lines).reduce((count, line) => { + const text = line.text.trim(); + if (!text || text.startsWith("%") || /^[A-Za-z][A-Za-z0-9_-]*:/.test(text)) return count; + return count + (text.match(/[A-Ga-gzZ]/g)?.length ?? 0); + }, 0); +} + +function byId(root: HTMLElement, id: string): T { + const element = root.querySelector(`#${id}`); + if (!element) throw new Error(`缺少界面元素 #${id}`); + return element; +} + +function safeFileBase(name: string | null): string { + const base = (name ?? "jianpu-project").replace(/\.(?:jpwabc|jianpu\.json)$/i, ""); + return base || "jianpu-project"; +} + +function starterDocument(): ScoreDocument { + const document = createEmptyDocument("document-starter"); + document.title.title = "新建简谱"; + document.title.composer = "Web 编辑器"; + const voiceId = freshId("voice"); + const measureId = freshId("measure"); + const first = createNoteEvent("starter-note-1", 1, rational(1)); + const second = createNoteEvent("starter-note-2", 2, rational(1)); + const third = createNoteEvent("starter-note-3", 3, rational(1)); + const rest = createRestEvent("starter-rest-1", rational(1)); + document.voices.push({ id: voiceId, name: "主旋律", sectionId: null, visible: true, measures: [{ id: measureId, ordinal: 0, beats: rational(4), events: [first, second, third, rest] }] }); + document.attachments.push({ + id: "starter-lyric", + kind: "Lyric", + raw: "W1:\n", + anchor: { raw: "starter-note-1", startId: first.id, endId: null }, + status: "attached", + sourceRange: null + }); + return document; +} + +function findEvent(document: ScoreDocument, id: StableId): ScoreEvent | null { + for (const voice of document.voices) for (const measure of voice.measures) { + const event = measure.events.find((candidate) => candidate.id === id); + if (event) return event; + } + return null; +} + +function findEventNeighbours(document: ScoreDocument): StableId[] { + const ids = document.voices.flatMap((voice) => voice.measures.flatMap((measure) => measure.events.map((event) => event.id))); + return ids; +} + +function lyricText(raw: string): string { + return raw.replace(/^W[^:]*:\s*/, "").replace(/^W[^:]*:\n?/, ""); +} + +export class EditorController { + private readonly root: HTMLElement; + private store: CommandStore; + private fileName: string | null = null; + private parseReport: ParseResult | null = null; + private layout: LayoutSnapshot | null = null; + private selection: Selection | null = null; + private selectedLyricId: StableId | null = null; + private selectedVoiceId: StableId | null = null; + private rendering = false; + private playbackTimeline: PlaybackTimeline | null = null; + private readonly playback: PlaybackController; + private readonly fontMeasurementProvider = createDefaultFontMeasurementProvider(); + private importWorker: Worker | null = null; + private cancelPendingImport: (() => void) | null = null; + private appPreferences: AppPreferences = loadAppPreferences(); + + constructor(root: HTMLElement) { + this.root = root; + this.root.innerHTML = ` +
+
+
+

JPW7 WEB

+

在线简谱编辑器

+
+
+ 新建文档 + 已保存 +
+
+ + + + +
+ +
+
+

谱面预览

+ +
+
+ + +
+
+
+
+
+ + +
+
+ `; + this.playback = new PlaybackController(null, { onError: (message) => this.showError(message) }); + this.playback.onStateChange((state) => this.updatePlaybackState(state)); + this.playback.onPositionChange((tick, seconds) => this.updatePlaybackPosition(tick, seconds)); + this.store = new CommandStore(starterDocument()); + this.bindEvents(); + void this.render("已创建新文档"); + } + + private bindEvents(): void { + byId(this.root, "new-document").addEventListener("click", () => this.newDocument()); + byId(this.root, "import-jpwabc").addEventListener("change", (event) => void this.importJpwabc((event.target as HTMLInputElement).files?.[0] ?? null)); + byId(this.root, "cancel-import").addEventListener("click", () => this.cancelImport()); + byId(this.root, "open-project").addEventListener("change", (event) => void this.openProject((event.target as HTMLInputElement).files?.[0] ?? null)); + byId(this.root, "save-project").addEventListener("click", () => void this.saveProject()); + byId(this.root, "export-jpwabc").addEventListener("click", () => this.exportJpwabc()); + byId(this.root, "export-svg").addEventListener("click", () => this.exportSvg()); + byId(this.root, "export-png").addEventListener("click", () => void this.exportPng()); + byId(this.root, "print-score").addEventListener("click", () => this.printScore()); + byId(this.root, "playback-play").addEventListener("click", () => void this.playback.play()); + byId(this.root, "playback-pause").addEventListener("click", () => this.playback.pause()); + byId(this.root, "playback-resume").addEventListener("click", () => void this.playback.resume()); + byId(this.root, "playback-stop").addEventListener("click", () => this.playback.stop()); + byId(this.root, "playback-position").addEventListener("input", (event) => this.playback.seek(Number((event.target as HTMLInputElement).value))); + byId(this.root, "undo").addEventListener("click", () => this.runCommand({ type: "undo" })); + byId(this.root, "redo").addEventListener("click", () => this.runCommand({ type: "redo" })); + this.root.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", () => { + const command = button.dataset.command; + const target = command ? this.root.querySelector(`#${command}`) : null; + target?.click(); + button.closest("details")?.removeAttribute("open"); + }); + }); + byId(this.root, "title-field").addEventListener("change", (event) => this.setTitle("title", (event.target as HTMLInputElement).value)); + byId(this.root, "composer-field").addEventListener("change", (event) => this.setTitle("composer", (event.target as HTMLInputElement).value)); + byId(this.root, "meter-field").addEventListener("change", (event) => this.setTitle("meter", (event.target as HTMLInputElement).value)); + byId(this.root, "tempo-field").addEventListener("change", (event) => this.setTitle("tempo", Number((event.target as HTMLInputElement).value) || null)); + byId(this.root, "app-preference-preview").addEventListener("change", (event) => { + this.appPreferences.previewMode = (event.target as HTMLSelectElement).value === "compact" ? "compact" : "standard"; + try { globalThis.localStorage?.setItem("jianpu-web:preferences", JSON.stringify(this.appPreferences)); } catch { /* storage may be unavailable; document model remains untouched */ } + this.showStatus("应用预览偏好已保存;工程模型与 DocumentOptions 未改变。"); + }); + byId(this.root, "pitch-down").addEventListener("click", () => this.shiftPitch(-1)); + byId(this.root, "pitch-up").addEventListener("click", () => this.shiftPitch(1)); + byId(this.root, "duration-down").addEventListener("click", () => this.shiftDuration(-1)); + byId(this.root, "duration-up").addEventListener("click", () => this.shiftDuration(1)); + byId(this.root, "delete-selection").addEventListener("click", () => this.deleteSelection()); + byId(this.root, "apply-advanced").addEventListener("click", () => this.applyAdvancedEdits()); + byId(this.root, "add-page-text").addEventListener("click", () => this.addPageText()); + byId(this.root, "lyric-selector").addEventListener("change", (event) => { + this.selectedLyricId = (event.target as HTMLSelectElement).value || null; + this.syncFields(); + }); + byId(this.root, "save-lyric").addEventListener("click", () => this.saveLyric()); + byId(this.root, "delete-lyric").addEventListener("click", () => this.deleteLyric()); + byId(this.root, "toggle-measure-lock").addEventListener("click", () => this.toggleMeasureFlag("lockedMeasureIds")); + byId(this.root, "toggle-measure-break").addEventListener("click", () => this.toggleMeasureFlag("forcedBreakAfterMeasureIds")); + byId(this.root, "measure-left").addEventListener("click", () => this.nudgeMeasure(-1)); + byId(this.root, "measure-right").addEventListener("click", () => this.nudgeMeasure(1)); + byId(this.root, "insert-measure").addEventListener("click", () => this.insertMeasureAfterSelection()); + byId(this.root, "delete-measure").addEventListener("click", () => this.deleteSelectedMeasure()); + byId(this.root, "voice-layout-selector").addEventListener("change", (event) => { + this.selectedVoiceId = (event.target as HTMLSelectElement).value || null; + this.syncFields(); + }); + byId(this.root, "voice-name").addEventListener("change", (event) => this.setVoiceLayout({ name: (event.target as HTMLInputElement).value })); + byId(this.root, "voice-alignment").addEventListener("change", (event) => this.setVoiceLayout({ alignment: (event.target as HTMLSelectElement).value as "aligned" | "independent" | "overlay" })); + byId(this.root, "voice-font-role").addEventListener("change", (event) => this.setVoiceLayout({ fontRole: (event.target as HTMLInputElement).value || null })); + byId(this.root, "toggle-voice-visible").addEventListener("click", () => { + const voice = this.store.snapshot().voices.find((candidate) => candidate.id === this.selectedVoiceId); + if (voice) this.setVoiceLayout({ visible: !voice.visible }); + }); + byId(this.root, "toggle-temporary-voice").addEventListener("click", () => { + const voice = this.store.snapshot().voices.find((candidate) => candidate.id === this.selectedVoiceId); + if (voice) this.setVoiceLayout({ temporary: !voice.temporary }); + }); + byId(this.root, "split-voice").addEventListener("click", () => this.splitVoice()); + byId(this.root, "merge-voice").addEventListener("click", () => this.mergeVoice()); + window.addEventListener("keydown", (event) => this.handleKey(event)); + window.addEventListener("beforeunload", (event) => { + if (this.store.dirty) { + event.preventDefault(); + event.returnValue = "工程尚未保存"; + } + }); + } + + private async newDocument(): Promise { + if (this.store.dirty && !window.confirm("当前工程有未保存修改,确定新建吗?")) return; + this.store = new CommandStore(starterDocument()); + this.fileName = null; + this.parseReport = null; + this.selection = null; + await this.render("已新建工程"); + } + + private cancelImport(): void { + if (!this.importWorker) return; + this.importWorker.terminate(); + this.importWorker = null; + this.cancelPendingImport?.(); + this.cancelPendingImport = null; + byId(this.root, "import-jpwabc").value = ""; + byId(this.root, "cancel-import").hidden = true; + this.showStatus("已取消导入;当前工程未改变。"); + } + + private async importJpwabc(file: File | null): Promise { + if (!file) return; + if (file.size > MAX_JPWABC_IMPORT_BYTES) { + byId(this.root, "import-jpwabc").value = ""; + this.showError(`导入已阻止:JPW-ABC 文件超过 512 KiB 上限(${file.size} 字节);当前工程未改变。`); + return; + } + const cancelButton = byId(this.root, "cancel-import"); + cancelButton.hidden = false; + try { + const bytes = await file.arrayBuffer(); + const worker = new Worker(new URL("../domain/jpwabc-worker.js", import.meta.url), { type: "module" }); + this.importWorker = worker; + const result = await new Promise<{ parsed: ParseResult; model: ScoreDocument } | null>((resolve, reject) => { + this.cancelPendingImport = () => resolve(null); + worker.addEventListener("message", (event: MessageEvent<{ parsed?: ParseResult; model?: ScoreDocument; error?: string }>) => { + if (event.data.error) reject(new Error(event.data.error)); + else if (event.data.parsed && event.data.model) resolve({ parsed: event.data.parsed, model: event.data.model }); + else reject(new Error("导入工作线程返回了无效结果")); + }, { once: true }); + worker.addEventListener("error", () => reject(new Error("导入工作线程失败")), { once: true }); + worker.postMessage({ bytes, fileName: file.name }, [bytes]); + }); + worker.terminate(); + this.importWorker = null; + this.cancelPendingImport = null; + if (!result) return; + const { parsed, model } = result; + const sourceEventTokens = countJpwabcEventTokens(parsed); + const modelEvents = model.voices.reduce((count, voice) => count + voice.measures.reduce((measureCount, measure) => measureCount + measure.events.length, 0), 0); + const eventCount = model.voices.length === 0 ? sourceEventTokens : modelEvents; + if (eventCount > MAX_JPWABC_EVENT_TOKENS) { + byId(this.root, "import-jpwabc").value = ""; + this.showError(`导入已阻止:工程包含约 ${eventCount} 个音符/事件,超过 ${MAX_JPWABC_EVENT_TOKENS} 上限;当前工程未改变。`); + return; + } + this.store = new CommandStore(model); + this.fileName = file.name; + this.parseReport = parsed; + this.selection = null; + await this.render(`已导入 ${file.name}`); + } catch (error) { + this.importWorker?.terminate(); + this.importWorker = null; + this.cancelPendingImport = null; + this.showError(error instanceof Error ? error.message : "导入失败"); + } finally { + cancelButton.hidden = true; + } + } + + private async openProject(file: File | null): Promise { + if (!file) return; + try { + const text = await file.text(); + const model = decodeProject(text); + this.store = new CommandStore(model); + this.fileName = file.name; + this.parseReport = null; + this.selection = null; + await this.render(`已打开工程 ${file.name}`); + } catch (error) { + this.showError(error instanceof Error ? error.message : "工程打开失败;原文未覆盖"); + } + } + + private async saveProject(): Promise { + try { + const text = encodeProject(this.store.snapshot()); + downloadText(`${safeFileBase(this.fileName)}.jianpu.json`, text, "application/json;charset=utf-8"); + this.store.markSaved(); + await this.render("工程已保存;兼容导出状态未改变"); + } catch (error) { + this.showError(error instanceof Error ? error.message : "工程保存失败"); + } + } + + private exportJpwabc(): void { + try { + const result = serializeJpwabc(this.store.snapshot()); + downloadBytes(`${safeFileBase(this.fileName)}.jpwabc`, result.bytes, "text/plain;charset=utf-16le"); + this.showStatus("已写出 M5 核心 .jpwabc;必须另行执行 JPW7 v7.30 原程序回验,工程脏状态未改变。"); + } catch (error) { + if (error instanceof CompatibilityExportBlockedError) this.showError(`${error.message} 请保存 .jianpu.json 工程;未知内容未被删除。`); + else this.showError(error instanceof Error ? error.message : "兼容导出失败;工程未改变"); + } + } + + private exportSvg(): void { + if (!this.layout) { + this.showError("当前没有可导出的 LayoutSnapshot。"); + return; + } + for (const [index, svg] of exportSnapshotSvg(this.layout).entries()) downloadText(`${safeFileBase(this.fileName)}-page-${index + 1}.svg`, svg, "image/svg+xml;charset=utf-8"); + this.showStatus(`已导出 ${this.layout.pages.length} 页 SVG;未包含选区和辅助层。`); + } + + private async exportPng(): Promise { + if (!this.layout) { + this.showError("当前没有可导出的 LayoutSnapshot。"); + return; + } + try { + const images = await renderSnapshotToPng(this.layout, this.layout.config.dpi); + for (const [index, image] of images.entries()) { + downloadBlob(`${safeFileBase(this.fileName)}-page-${index + 1}.png`, image); + await new Promise((resolve) => window.setTimeout(resolve, 40)); + } + this.showStatus(`已导出 ${images.length} 页 PNG(${this.layout.config.dpi} DPI)。`); + } catch (error) { + this.showError(error instanceof Error ? error.message : "PNG 导出失败;工程未改变"); + } + } + + private printScore(): void { + if (!this.layout) { + this.showError("当前没有可打印的 LayoutSnapshot。"); + return; + } + const result = printSnapshot(this.layout); + if (result.printed) this.showStatus(`已打开 ${result.pageCount} 页 A4 打印 / PDF 对话框;请使用 100% 缩放并关闭浏览器页眉页脚。`); + else this.showError(result.reason ?? "当前环境无法打印"); + } + + private updatePlaybackState(state: "stopped" | "playing" | "paused"): void { + const labels = { stopped: "已停止", playing: "播放中", paused: "已暂停" }; + const output = byId(this.root, "playback-state"); + output.textContent = labels[state]; + byId(this.root, "playback-play").disabled = state === "playing"; + byId(this.root, "playback-pause").disabled = state !== "playing"; + byId(this.root, "playback-resume").disabled = state !== "paused"; + byId(this.root, "playback-stop").disabled = state === "stopped"; + } + + private updatePlaybackPosition(tick: number, seconds: number): void { + const timeline = this.playbackTimeline; + const input = byId(this.root, "playback-position"); + const duration = timeline?.durationTicks ?? 0; + input.max = String(duration); + input.value = String(Math.min(duration, Math.max(0, Math.round(tick)))); + const total = timeline?.secondsAtTick(duration) ?? 0; + byId(this.root, "playback-time").textContent = `${seconds.toFixed(3)} s / ${total.toFixed(3)} s`; + for (const element of this.root.querySelectorAll("[data-playback-active]")) element.removeAttribute("data-playback-active"); + const active = timeline?.events.find((event) => event.kind === "note" && tick >= event.startTick && tick < event.startTick + event.durationTicks); + if (active) this.root.querySelector(`[data-id="${CSS.escape(active.eventId)}"]`)?.setAttribute("data-playback-active", "true"); + } + + private updatePlaybackPanel(): void { + const timeline = this.playbackTimeline; + const warning = byId(this.root, "playback-warning"); + warning.textContent = timeline?.warnings.length ? timeline.warnings.join(" ") : "时间线按语义模型编译;休止占时但不发声。"; + const container = byId(this.root, "voice-controls"); + container.replaceChildren(); + if (!timeline) return; + const documentModel = this.store.snapshot(); + for (const [index, voiceId] of timeline.voiceIds.entries()) { + const voice = documentModel.voices.find((candidate) => candidate.id === voiceId); + const control = this.playback.voiceControls[voiceId] ?? { muted: false, solo: false, volume: 1, waveform: "sine" as const }; + const row = document.createElement("div"); + row.className = "voice-control"; + row.dataset.voiceId = voiceId; + const name = document.createElement("strong"); + name.textContent = voice?.name || `声部 ${index + 1}`; + row.appendChild(name); + const mute = document.createElement("input"); + mute.type = "checkbox"; + mute.checked = control.muted; + mute.setAttribute("aria-label", `静音 ${voice?.name || `声部 ${index + 1}`}`); + mute.addEventListener("change", () => this.playback.setVoiceControl(voiceId, { muted: mute.checked })); + const muteLabel = document.createElement("label"); + muteLabel.append("静音 ", mute); + row.appendChild(muteLabel); + const solo = document.createElement("input"); + solo.type = "checkbox"; + solo.checked = control.solo; + solo.setAttribute("aria-label", `独奏 ${voice?.name || `声部 ${index + 1}`}`); + solo.addEventListener("change", () => this.playback.setVoiceControl(voiceId, { solo: solo.checked })); + const soloLabel = document.createElement("label"); + soloLabel.append("独奏 ", solo); + row.appendChild(soloLabel); + const volume = document.createElement("input"); + volume.type = "range"; + volume.min = "0"; + volume.max = "1"; + volume.step = "0.01"; + volume.value = String(control.volume); + volume.setAttribute("aria-label", `音量 ${voice?.name || `声部 ${index + 1}`}`); + volume.addEventListener("input", () => this.playback.setVoiceControl(voiceId, { volume: Number(volume.value) })); + row.appendChild(volume); + const waveform = document.createElement("select"); + waveform.setAttribute("aria-label", `音色 ${voice?.name || `声部 ${index + 1}`}`); + for (const option of ["sine", "triangle", "square", "sawtooth"] as const) { + const choice = document.createElement("option"); + choice.value = option; + choice.textContent = option; + choice.selected = option === control.waveform; + waveform.appendChild(choice); + } + waveform.addEventListener("change", () => this.playback.setVoiceControl(voiceId, { waveform: waveform.value as PlaybackVoiceControl["waveform"] })); + row.appendChild(waveform); + container.appendChild(row); + } + this.updatePlaybackPosition(this.playback.currentTick, this.playback.currentSeconds); + this.updatePlaybackState(this.playback.state); + } + + private setTitle(field: "title" | "composer" | "meter" | "tempo", value: string | number | null): void { + this.runCommand({ type: "set-title", field, value }); + } + + private selectedEvent(): ScoreEvent | null { + return this.selection?.kind === "event" ? findEvent(this.store.snapshot(), this.selection.id) : null; + } + + private shiftPitch(delta: number): void { + const event = this.selectedEvent(); + if (!event || !event.pitch) return; + const degree = Math.min(7, Math.max(0, (event.pitch.degree ?? 1) + delta)); + this.runCommand({ type: "update-event", eventId: event.id, patch: { pitch: { ...event.pitch, degree } } }); + } + + private shiftDuration(delta: number): void { + const event = this.selectedEvent(); + if (!event?.duration) return; + const numerator = Math.max(1, event.duration.numerator + delta); + this.runCommand({ type: "update-event", eventId: event.id, patch: { duration: rational(numerator, event.duration.denominator) } }); + } + + private deleteSelection(): void { + if (!this.selection) return; + if (this.selection.kind === "event") this.runCommand({ type: "delete-event", eventId: this.selection.id }); + else if (this.selection.kind === "attachment") this.runCommand({ type: "delete-attachment", attachmentId: this.selection.id }); + else return; + this.selection = null; + } + + private selectedMeasure(): ScoreDocument["voices"][number]["measures"][number] | null { + const measureId = this.selectedMeasureId(); + if (!measureId) return null; + for (const voice of this.store.snapshot().voices) { + const measure = voice.measures.find((candidate) => candidate.id === measureId); + if (measure) return measure; + } + return null; + } + + private applyAdvancedEdits(): void { + const event = this.selectedEvent(); + const measure = this.selectedMeasure(); + if (event) { + const patch: EventPatch = {}; + const chordText = byId(this.root, "event-chord").value.trim(); + if (chordText) { + if (!event.pitch) { + this.showError("当前事件没有音高,不能建立和弦。"); + return; + } + patch.chord = chordText.split(",").map((token) => Number(token.trim())).filter((degree) => Number.isInteger(degree) && degree >= 0 && degree <= 7).map((degree) => ({ ...event.pitch!, degree })); + } else patch.chord = []; + const decorationText = byId(this.root, "event-decorations").value.trim(); + patch.decorations = decorationText ? decorationText.split(",").map((token) => token.trim()).filter(Boolean).map((kind) => ({ id: freshId("decoration"), kind, raw: kind, playback: kind.startsWith("w1:") ? "w1" as const : "visual" as const, sourceRange: null })) : []; + const tupletRatio = Number(byId(this.root, "event-tuplet-ratio").value); + patch.tuplet = Number.isInteger(tupletRatio) && tupletRatio >= 2 ? { id: freshId("tuplet"), ratio: tupletRatio, ordinal: 1, raw: `${tupletRatio}:1` } : null; + this.runCommand({ type: "update-event", eventId: event.id, patch }); + } + if (measure) { + const value = (id: string): string | null => byId(this.root, id).value || null; + const endingText = byId(this.root, "measure-ending").value; + const patch: MeasurePatch = { + barlineBefore: (value("barline-before") ?? undefined) as BarlineStyle | undefined, + barlineAfter: (value("barline-after") ?? undefined) as BarlineStyle | undefined, + repeatStart: byId(this.root, "repeat-start").checked, + repeatEnd: byId(this.root, "repeat-end").checked, + ending: endingText ? Number(endingText) : null + }; + this.runCommand({ type: "update-measure", measureId: measure.id, patch }); + if (event) { + const x = Number(byId(this.root, "symbol-offset-x").value) || 0; + const y = Number(byId(this.root, "symbol-offset-y").value) || 0; + const positions = { ...(this.store.snapshot().manualLayout.objectPositions ?? {}) }; + if (x === 0 && y === 0) delete positions[event.id]; + else positions[event.id] = { x, y }; + this.runCommand({ type: "set-manual-layout", patch: { objectPositions: positions } }); + } + } + } + + private selectedMeasureId(): StableId | null { + if (!this.selection || !this.layout) return null; + if (this.selection.kind === "event") return this.layout.symbols[this.selection.id]?.measureId ?? null; + return this.selection.kind === "measure" ? this.selection.id : null; + } + + private toggleMeasureFlag(field: "lockedMeasureIds" | "forcedBreakAfterMeasureIds"): void { + const measureId = this.selectedMeasureId(); + if (!measureId) { + this.showError("请先选择一个音符或小节。"); + return; + } + const values = [...(this.store.snapshot().manualLayout[field] ?? [])]; + const index = values.indexOf(measureId); + if (index >= 0) values.splice(index, 1); + else values.push(measureId); + this.runCommand({ type: "set-manual-layout", patch: { [field]: values } }); + } + + private nudgeMeasure(delta: number): void { + const measureId = this.selectedMeasureId(); + if (!measureId) { + this.showError("请先选择一个音符或小节。"); + return; + } + const positions = { ...(this.store.snapshot().manualLayout.measurePositions ?? {}) }; + const position = positions[measureId] ?? { x: 0, y: 0 }; + positions[measureId] = { x: position.x + delta, y: position.y }; + this.runCommand({ type: "set-manual-layout", patch: { measurePositions: positions } }); + } + + private insertMeasureAfterSelection(): void { + const measure = this.selectedMeasure(); + const voice = this.store.snapshot().voices.find((candidate) => candidate.measures.some((item) => item.id === measure?.id)); + if (!measure || !voice) return; + const index = voice.measures.findIndex((item) => item.id === measure.id); + this.runCommand({ type: "insert-measure", voiceId: voice.id, index: index + 1, measure: { id: freshId("measure"), ordinal: index + 1, beats: measure.beats, events: [createRestEvent(freshId("rest"), measure.beats)] } }); + } + + private deleteSelectedMeasure(): void { + const measure = this.selectedMeasure(); + const voice = this.store.snapshot().voices.find((candidate) => candidate.measures.some((item) => item.id === measure?.id)); + if (!measure || !voice) return; + this.runCommand({ type: "delete-measure", voiceId: voice.id, measureId: measure.id }); + } + + private setVoiceLayout(patch: VoiceLayoutPatch): void { + if (!this.selectedVoiceId) return; + this.runCommand({ type: "set-voice-layout", voiceId: this.selectedVoiceId, patch }); + } + + private splitVoice(): void { + if (!this.selectedVoiceId) return; + const atMeasure = Number(byId(this.root, "voice-split-at").value); + const voice = this.store.snapshot().voices.find((candidate) => candidate.id === this.selectedVoiceId); + if (!voice) return; + const newVoiceId = freshId("voice-split"); + this.runCommand({ type: "split-voice", voiceId: voice.id, atMeasure, newVoiceId, newName: `${voice.name}(拆分)` }); + this.selectedVoiceId = newVoiceId; + } + + private mergeVoice(): void { + if (!this.selectedVoiceId) return; + const sourceVoiceId = byId(this.root, "voice-merge-source").value; + if (!sourceVoiceId) return; + this.runCommand({ type: "merge-voices", targetVoiceId: this.selectedVoiceId, sourceVoiceId, strategy: "append" }); + } + + private addPageText(): void { + const raw = byId(this.root, "page-text-field").value.trim(); + if (!raw) { + this.showError("请输入页面文本框内容。"); + return; + } + const object: PageObjectModel = { + id: freshId("page-object"), + kind: "TextBox", + raw, + position: { x: Number(byId(this.root, "page-text-x").value) || 20, y: Number(byId(this.root, "page-text-y").value) || 270 }, + visible: true, + sourceRange: null + }; + this.runCommand({ type: "insert-page-object", object }); + byId(this.root, "page-text-field").value = ""; + } + + private saveLyric(): void { + const lyric = this.store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric" && attachment.id === this.selectedLyricId) ?? this.store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric"); + if (!lyric) { + this.showError("当前工程没有可编辑的歌词锚点;请导入包含 .Words 的样例或在后续复杂歌词里程碑创建。"); + return; + } + const value = byId(this.root, "lyric-field").value; + this.runCommand({ type: "set-attachment-raw", attachmentId: lyric.id, raw: `${lyric.raw.split("\n", 1)[0]}\n${value}` }); + } + + private deleteLyric(): void { + const lyric = this.store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric" && attachment.id === this.selectedLyricId) ?? this.store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric"); + if (lyric) this.runCommand({ type: "delete-attachment", attachmentId: lyric.id }); + } + + private updatePitchView(eventId: StableId): boolean { + const event = findEvent(this.store.snapshot(), eventId); + const symbol = this.layout?.symbols[eventId]; + if (!event?.pitch || !symbol) return false; + const text = event.source?.raw ?? `${event.pitch.accidental < 0 ? "b" : event.pitch.accidental > 0 ? "#" : ""}${event.pitch.degree ?? "•"}`; + symbol.text = text; + const group = this.root.querySelector(`[data-id="${CSS.escape(eventId)}"]`); + const textNode = group?.querySelector("text:not([data-layer])"); + if (!textNode) return false; + textNode.textContent = text; + const voiceControls = this.playback.voiceControls; + this.playbackTimeline = compilePlaybackTimeline(this.store.snapshot()); + this.playback.load(this.playbackTimeline); + for (const [voiceId, control] of Object.entries(voiceControls)) this.playback.setVoiceControl(voiceId, control); + this.updateSelectionPanel(); + this.updateState(); + byId(this.root, "undo").disabled = !this.store.canUndo; + byId(this.root, "redo").disabled = !this.store.canRedo; + this.showStatus("修改已应用"); + return true; + } + + private runCommand(command: EditCommand | { type: "undo" } | { type: "redo" }): void { + try { + if (command.type === "undo") this.store.undo(); + else if (command.type === "redo") this.store.redo(); + else this.store.execute(command); + if (command.type === "update-event" && Object.keys(command.patch).length === 1 && command.patch.pitch && this.updatePitchView(command.eventId)) return; + void this.render("修改已应用"); + } catch (error) { + if (error instanceof CommandRejectedError) this.showError(error.diagnostics.map((diagnostic) => diagnostic.message).join(";")); + else this.showError(error instanceof Error ? error.message : "编辑命令失败;当前模型未改变"); + } + } + + private select(id: StableId, kind: string): void { + this.selection = { id, kind }; + if (kind === "event") { + const timelineEvent = this.playbackTimeline?.events.find((event) => event.eventId === id); + if (timelineEvent) this.playback.seek(timelineEvent.startTick); + } + this.updateSelectionPanel(); + this.root.querySelectorAll("[data-id]").forEach((node) => node.classList.toggle("selected", node.dataset.id === id)); + this.showStatus(`已选中 ${kind} ${id}`); + } + + private handleKey(event: KeyboardEvent): void { + const target = event.target as HTMLElement | null; + const typing = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA"; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + void this.saveProject(); + return; + } + if (typing) return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") { + event.preventDefault(); + this.runCommand({ type: event.shiftKey ? "redo" : "undo" }); + return; + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") { + event.preventDefault(); + this.runCommand({ type: "redo" }); + return; + } + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + this.deleteSelection(); + return; + } + if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && this.selection?.kind === "event") { + event.preventDefault(); + const ids = findEventNeighbours(this.store.snapshot()); + const index = ids.indexOf(this.selection.id); + const next = ids[Math.max(0, Math.min(ids.length - 1, index + (event.key === "ArrowRight" ? 1 : -1)))]; + if (next) this.select(next, "event"); + } + } + + private async render(message: string): Promise { + if (this.rendering) return; + this.rendering = true; + try { + this.syncFields(); + this.layout = await layoutDocument(this.store.snapshot(), DEFAULT_PAPER_CONFIG, this.fontMeasurementProvider); + this.playbackTimeline = compilePlaybackTimeline(this.store.snapshot()); + this.playback.load(this.playbackTimeline); + this.updatePlaybackPanel(); + const pages = byId(this.root, "paper-pages"); + pages.replaceChildren(); + const renderedSvgs = renderSnapshotSvg(this.layout, false); + const parser = new DOMParser(); + for (const [index, svgText] of renderedSvgs.entries()) { + const parsedSvg = parser.parseFromString(svgText, "image/svg+xml"); + const svg = parsedSvg.documentElement; + if (svg.localName !== "svg") throw new Error("SVG 渲染探针返回了无效文档"); + const page = document.createElement("div"); + page.className = "paper-page"; + page.dataset.page = String(index + 1); + page.appendChild(document.importNode(svg, true)); + pages.appendChild(page); + } + pages.querySelectorAll("[data-id]").forEach((node) => { + const id = node.dataset.id ?? ""; + const kind = node.dataset.kind === "page-object" ? "page-object" : node.dataset.kind === "attachment" ? "attachment" : node.dataset.kind === "line" ? "line" : node.dataset.kind === "measure" ? "measure" : "event"; + node.setAttribute("role", "button"); + node.setAttribute("tabindex", "0"); + node.setAttribute("aria-label", `选择${kind} ${id}`); + node.addEventListener("click", () => this.select(id, kind)); + node.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.select(id, kind); + } + }); + node.classList.toggle("selected", this.selection?.id === id); + }); + this.updateSelectionPanel(); + this.updateParseReport(); + const summary = layoutSummary(this.layout); + const probe = renderedSvgs[0] ? probeSvg(renderedSvgs[0]) : null; + byId(this.root, "layout-summary").textContent = `${summary.pageCount} 页 · ${summary.lineCount} 行 · ${summary.measureCount} 小节${probe ? ` · ${probe.width} / ${probe.height}` : ""}`; + this.updateFooterMetrics(summary); + this.updateState(); + this.showStatus(message); + if (this.selection) this.root.querySelector(`[data-id="${CSS.escape(this.selection.id)}"]`)?.focus({ preventScroll: true }); + } catch (error) { + this.showError(error instanceof Error ? error.message : "布局失败;当前工程仍保留"); + } finally { + this.rendering = false; + } + } + + private syncFields(): void { + const document = this.store.snapshot(); + byId(this.root, "title-field").value = document.title.title; + byId(this.root, "composer-field").value = document.title.composer; + byId(this.root, "meter-field").value = document.title.meter; + byId(this.root, "tempo-field").value = document.title.tempo === null ? "" : String(document.title.tempo); + byId(this.root, "app-preference-preview").value = this.appPreferences.previewMode; + const lyrics = document.attachments.filter((attachment) => attachment.kind === "Lyric"); + const selector = byId(this.root, "lyric-selector"); + const selected = lyrics.some((lyric) => lyric.id === this.selectedLyricId) ? this.selectedLyricId : lyrics[0]?.id ?? null; + this.selectedLyricId = selected; + selector.replaceChildren(...lyrics.map((lyric, index) => { + const option = globalThis.document.createElement("option"); + option.value = lyric.id; + option.textContent = lyric.lyric?.verseStart ? `第 ${lyric.lyric.verseStart}${lyric.lyric.verseEnd ? `-${lyric.lyric.verseEnd}` : ""} 段` : `歌词 ${index + 1}`; + option.selected = lyric.id === selected; + return option; + })); + const lyric = lyrics.find((candidate) => candidate.id === selected); + byId(this.root, "lyric-field").value = lyric ? lyricText(lyric.raw) : ""; + const voices = document.voices; + const voiceSelector = byId(this.root, "voice-layout-selector"); + const selectedVoice = voices.some((voice) => voice.id === this.selectedVoiceId) ? this.selectedVoiceId : voices[0]?.id ?? null; + this.selectedVoiceId = selectedVoice; + voiceSelector.replaceChildren(...voices.map((voice, index) => { + const option = globalThis.document.createElement("option"); + option.value = voice.id; + option.textContent = `${index + 1}. ${voice.name}${voice.temporary ? "(临时)" : ""}`; + option.selected = voice.id === selectedVoice; + return option; + })); + const voice = voices.find((candidate) => candidate.id === selectedVoice); + byId(this.root, "voice-name").value = voice?.name ?? ""; + byId(this.root, "voice-alignment").value = voice?.alignment ?? "aligned"; + const mergeSelector = byId(this.root, "voice-merge-source"); + mergeSelector.replaceChildren(...voices.filter((candidate) => candidate.id !== selectedVoice).map((candidate) => { + const option = globalThis.document.createElement("option"); + option.value = candidate.id; + option.textContent = candidate.name; + return option; + })); + byId(this.root, "voice-font-role").value = voice?.fontRole ?? ""; + this.updateManualSelection(); + byId(this.root, "undo").disabled = !this.store.canUndo; + byId(this.root, "redo").disabled = !this.store.canRedo; + } + + private updateState(): void { + byId(this.root, "file-name").textContent = this.fileName ?? "新建文档"; + const state = byId(this.root, "dirty-state"); + state.textContent = this.store.dirty ? "未保存 *" : "已保存"; + state.classList.toggle("dirty", this.store.dirty); + } + + private updateSelectionPanel(): void { + const empty = byId(this.root, "selection-empty"); + const details = byId(this.root, "selection-details"); + if (!this.selection) { + empty.hidden = false; + details.hidden = true; + this.updateManualSelection(); + this.updateAdvancedFields(); + byId(this.root, "pitch-down").disabled = true; + byId(this.root, "pitch-up").disabled = true; + byId(this.root, "duration-down").disabled = true; + byId(this.root, "duration-up").disabled = true; + byId(this.root, "delete-selection").disabled = true; + this.updateToolbarState(); + return; + } + empty.hidden = true; + details.hidden = false; + byId(this.root, "selected-id").textContent = this.selection.id; + byId(this.root, "selected-kind").textContent = this.selection.kind; + const event = this.selection.kind === "event" ? findEvent(this.store.snapshot(), this.selection.id) : null; + byId(this.root, "selected-value").textContent = event ? `${event.kind} ${event.text ?? event.pitch?.degree ?? ""} · 时值 ${event.duration ? `${event.duration.numerator}/${event.duration.denominator}` : "无"}` : "保留对象/页面对象"; + const isEvent = Boolean(event?.pitch); + byId(this.root, "pitch-down").disabled = !isEvent; + byId(this.root, "pitch-up").disabled = !isEvent; + byId(this.root, "duration-down").disabled = !event?.duration; + byId(this.root, "duration-up").disabled = !event?.duration; + byId(this.root, "delete-selection").disabled = this.selection.kind !== "event" && this.selection.kind !== "attachment"; + this.updateManualSelection(); + this.updateAdvancedFields(); + this.updateToolbarState(); + } + + private updateAdvancedFields(): void { + const event = this.selectedEvent(); + const measure = this.selectedMeasure(); + byId(this.root, "event-chord").value = event?.chord?.map((pitch) => String(pitch.degree ?? "")).join(",") ?? ""; + byId(this.root, "event-decorations").value = event?.decorations?.map((decoration) => decoration.kind).join(",") ?? ""; + byId(this.root, "event-tuplet-ratio").value = event?.tuplet ? String(event.tuplet.ratio) : ""; + byId(this.root, "barline-before").value = measure?.barlineBefore ?? ""; + byId(this.root, "barline-after").value = measure?.barlineAfter ?? ""; + byId(this.root, "repeat-start").checked = Boolean(measure?.repeatStart); + byId(this.root, "repeat-end").checked = Boolean(measure?.repeatEnd); + byId(this.root, "measure-ending").value = measure?.ending == null ? "" : String(measure.ending); + byId(this.root, "symbol-offset-x").value = event ? String(this.store.snapshot().manualLayout.objectPositions?.[event.id]?.x ?? 0) : ""; + byId(this.root, "symbol-offset-y").value = event ? String(this.store.snapshot().manualLayout.objectPositions?.[event.id]?.y ?? 0) : ""; + byId(this.root, "apply-advanced").disabled = !event && !measure; + } + + private updateManualSelection(): void { + const document = this.store.snapshot(); + const measureId = this.selectedMeasureId(); + const manualSelection = byId(this.root, "manual-selection"); + manualSelection.textContent = measureId ? `当前小节:${measureId}${document.manualLayout.lockedMeasureIds.includes(measureId) ? " · 已锁定" : ""}${document.manualLayout.forcedBreakAfterMeasureIds.includes(measureId) ? " · 强制换行" : ""}` : "先点击谱面中的音符选择小节。"; + this.updateFooterSelection(); + } + + private updateFooterSelection(): void { + byId(this.root, "footer-selection").textContent = this.selectedMeasureId() ?? "未选择"; + } + + private updateFooterMetrics(summary: ReturnType): void { + this.updateFooterSelection(); + byId(this.root, "footer-layout").textContent = `${summary.pageCount} 页 · ${summary.lineCount} 行 · ${summary.measureCount} 小节`; + } + + private updateToolbarState(): void { + const measureCommands = new Set(["toggle-measure-lock", "toggle-measure-break", "measure-left", "measure-right"]); + const lyricCommands = new Set(["save-lyric", "delete-lyric"]); + const hasLyric = this.store.snapshot().attachments.some((attachment) => attachment.kind === "Lyric"); + const hasMeasure = Boolean(this.selectedMeasureId()); + const hasVoice = Boolean(this.selectedVoiceId); + this.root.querySelectorAll(".toolbar-tertiary [data-command]").forEach((button) => { + const command = button.dataset.command ?? ""; + const target = this.root.querySelector(`#${command}`); + button.disabled = Boolean(target?.disabled) || (measureCommands.has(command) && !hasMeasure) || (lyricCommands.has(command) && !hasLyric) || (["toggle-voice-visible", "toggle-temporary-voice"].includes(command) && !hasVoice); + }); + const advanced = byId(this.root, "apply-advanced"); + advanced.disabled = !this.selectedEvent() && !hasMeasure; + } + + private updateParseReport(): void { + const report = byId(this.root, "parse-report"); + if (!this.parseReport) { + report.textContent = "当前为工程模型;没有新的 JPW-ABC 导入报告。\n工程保存仍保留未知块、源字节和稳定 ID。"; + return; + } + const summary = { + encoding: this.parseReport.source.encoding, + bom: this.parseReport.source.bom, + sections: this.parseReport.sections.map((section) => section.name), + voices: this.parseReport.voices.length, + words: this.parseReport.words.length, + attachments: this.parseReport.attachments.length, + unknownBlocks: this.parseReport.unknownBlocks.length, + unknownTokens: this.parseReport.unknownTokens.length, + diagnostics: this.parseReport.diagnostics.map((diagnostic) => `${diagnostic.severity}: ${diagnostic.code} ${diagnostic.message}`), + compatibility: this.parseReport.compatibility + }; + report.textContent = JSON.stringify(summary, null, 2); + } + + private showStatus(message: string): void { + const output = byId(this.root, "status-message"); + output.dataset.kind = "success"; + output.textContent = message; + } + + private showError(message: string): void { + const output = byId(this.root, "status-message"); + output.dataset.kind = "error"; + output.textContent = message; + } +} + +function downloadBlob(fileName: string, blob: Blob): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function downloadBytes(fileName: string, bytes: Uint8Array, mime: string): void { + const copy = new ArrayBuffer(bytes.byteLength); + new Uint8Array(copy).set(bytes); + downloadBlob(fileName, new Blob([copy], { type: mime })); +} + +function downloadText(fileName: string, content: string, mime: string): void { + downloadBlob(fileName, new Blob([content], { type: mime })); +} + +export function mountEditor(root: HTMLElement): EditorController { + return new EditorController(root); +} diff --git a/src/app/styles.css b/src/app/styles.css new file mode 100644 index 0000000..f94eaff --- /dev/null +++ b/src/app/styles.css @@ -0,0 +1,102 @@ +:root { + color-scheme: light; + font-family: "Noto Sans", "Noto Sans CJK SC", system-ui, sans-serif; + color: #1b2533; + background: #eef2f6; + font-size: 15px; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 960px; background: #eef2f6; } +.app-shell { min-height: 100vh; display: flex; flex-direction: column; } +button, input, textarea { font: inherit; } +button { border: 1px solid #aeb9c6; border-radius: 5px; background: #fff; color: #1b2533; padding: 0.45rem 0.75rem; cursor: pointer; } +button:hover:not(:disabled) { background: #e8f0fa; border-color: #527da9; } +button:focus-visible, input:focus-visible, textarea:focus-visible, summary:focus-visible, [data-id]:focus-visible { outline: 3px solid #1677c8; outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: 0.45; } +button.danger { color: #9d241e; } +.app-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; height: 30px; padding: 0 0.65rem; background: #1d2b3a; color: #f5f8fb; border-bottom: 1px solid #0d1722; } +.eyebrow { display: none; } +h1, h2 { margin: 0; } +.app-header h1 { font-size: 0.9rem; line-height: 1; font-weight: 600; } +.document-state { display: flex; align-items: center; gap: 0.45rem; font-size: 0.72rem; } +.state-pill { border-radius: 2px; background: #2f7454; padding: 0.15rem 0.4rem; white-space: nowrap; } +.state-pill.dirty { background: #a55c1d; } +.menu-bar { display: flex; align-items: stretch; height: 20px; padding: 0 0.25rem; background: #eceff2; border-bottom: 1px solid #b5bdc7; color: #263442; } +.menu-item { position: relative; } +.menu-item summary { list-style: none; cursor: default; padding: 0 0.62rem; height: 19px; line-height: 19px; font-size: 0.74rem; user-select: none; } +.menu-item summary::-webkit-details-marker { display: none; } +.menu-item:hover summary, .menu-item[open] summary { background: #d7e4f2; color: #15395d; } +.menu-popup { position: absolute; z-index: 30; top: 100%; left: 0; min-width: 11rem; padding: 0.25rem; border: 1px solid #9da9b5; background: #fff; box-shadow: 0 4px 12px #17253733; } +.menu-popup button, .menu-popup span { display: block; width: 100%; border: 0; border-radius: 2px; background: transparent; color: #263442; text-align: left; padding: 0.34rem 0.55rem; font-size: 0.82rem; } +.menu-popup button:hover:not(:disabled) { background: #dceaf8; border-color: transparent; } +.menu-popup-note { min-width: 12rem; } +.menu-popup-note span { color: #657487; } +.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; padding: 0 0.5rem; background: #f7f8f9; border-bottom: 1px solid #c9d3de; } +.toolbar-main { min-height: 60px; } +.toolbar-secondary { min-height: 33px; background: #eef1f4; } +.toolbar-tertiary { min-height: 28px; gap: 0; background: #e7ebef; } +.toolbar button, .toolbar .file-button { min-height: 27px; border-radius: 2px; padding: 0.28rem 0.55rem; font-size: 0.78rem; } +.toolbar-main button, .toolbar-main .file-button { min-height: 46px; } +.toolbar-tertiary button { min-width: 67px; min-height: 28px; padding: 0 0.35rem; border-color: #c2cbd5; border-radius: 0; background: #f5f7f9; font-size: 0.7rem; } +.toolbar-tertiary button + button { border-left-width: 0; } +.toolbar-tertiary button:disabled { color: #5a6878; background: #e9edf1; } +.toolbar-hint { padding: 0 0.65rem 0 0.25rem; color: #5a6878; font-size: 0.68rem; white-space: nowrap; } +.toolbar-separator { flex: 1 1 auto; } +.file-button { display: inline-flex; align-items: center; border: 1px solid #aeb9c6; border-radius: 5px; background: #fff; padding: 0.45rem 0.75rem; cursor: pointer; } +.file-button:hover { background: #e8f0fa; border-color: #527da9; } +.file-button input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } +.workspace { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: calc(100vh - 208px); } +.inspector { padding: 1rem; background: #f7f9fb; border-right: 1px solid #c9d3de; overflow-y: auto; } +.panel { margin-bottom: 0.8rem; padding: 0.8rem; border: 1px solid #d1dae4; border-radius: 7px; background: #fff; box-shadow: 0 1px 2px #1725370d; } +.panel h2, .panel summary { font-size: 0.98rem; font-weight: 650; margin-bottom: 0.65rem; } +.panel label { display: block; margin: 0.55rem 0; font-size: 0.83rem; color: #4d5b6b; } +.panel input, .panel textarea { display: block; width: 100%; margin-top: 0.25rem; padding: 0.4rem 0.5rem; border: 1px solid #bdc8d4; border-radius: 4px; background: #fbfcfd; color: #172537; } +.panel textarea { resize: vertical; line-height: 1.45; } +.selection-meta { margin: 0 0 0.7rem; } +.selection-meta div { display: grid; grid-template-columns: 4rem minmax(0, 1fr); gap: 0.35rem; padding: 0.2rem 0; border-bottom: 1px solid #edf0f3; } +.selection-meta dt { color: #657487; } +.selection-meta dd { margin: 0; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.78rem; } +.button-row { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.button-row button { flex: 1 1 auto; font-size: 0.8rem; } +.help-text, #selection-empty { color: #657487; font-size: 0.8rem; line-height: 1.45; } +.report-panel summary { cursor: pointer; } +#parse-report { margin: 0; max-height: 260px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #405267; font-size: 0.72rem; line-height: 1.4; } +.paper-workspace { min-width: 0; padding: 1rem 1.25rem 2rem; overflow: auto; } +.paper-toolbar { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 0.45rem; color: #31455b; } +.paper-toolbar h2 { font-size: 1.05rem; } +#layout-summary { color: #657487; font-size: 0.8rem; } +.paper-tabs { display: flex; align-items: stretch; gap: 0.1rem; min-height: 20px; margin: 0 -0.25rem 0.75rem; border-bottom: 1px solid #b8c2ce; } +.paper-tabs button { min-width: 3.4rem; padding: 0.12rem 0.45rem; border: 1px solid transparent; border-bottom: 0; border-radius: 2px 2px 0 0; background: transparent; color: #526477; font-size: 0.74rem; } +.paper-tabs button.active { border-color: #b8c2ce; background: #fff; color: #263442; font-weight: 650; } +.paper-tabs button:disabled { cursor: not-allowed; color: #5a6878; opacity: 0.8; } +.paper-pages { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 1.25rem; } +.paper-page { width: min(210mm, calc(100vw - 340px)); min-width: 540px; background: #fff; box-shadow: 0 5px 22px #17253726; } +.paper-page svg { display: block; width: 100%; height: auto; aspect-ratio: 210 / 297; } +.paper-page svg [data-id] { cursor: pointer; } +.paper-page svg [data-id].selected { fill: #b42318; stroke: #b42318; stroke-width: 0.5; paint-order: stroke; } +.paper-page svg [data-layer="assistive"] { pointer-events: none; } +.app-footer { min-height: 1.2rem; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.15rem 0.75rem; background: #fff; border-top: 1px solid #c9d3de; color: #526477; font-size: 0.68rem; } +.footer-metrics { display: flex; align-items: center; gap: 0.7rem; min-width: 0; overflow: hidden; white-space: nowrap; } +.footer-metric { padding-right: 0.65rem; border-right: 1px solid #d8dee5; } +.footer-metric:last-child { border-right: 0; } +#status-message { flex: 0 1 auto; min-width: 8rem; font-size: 0.75rem; color: #236342; text-align: right; } +#status-message[data-kind="error"] { color: #a12620; } +#status-message[data-kind="error"] { color: #a12620; } +.playback-panel output { display: inline-block; margin-bottom: 0.35rem; font-weight: 650; color: #236342; } +.voice-controls { display: grid; gap: 0.45rem; } +.voice-control { display: grid; grid-template-columns: minmax(5rem, 1fr) auto auto minmax(4rem, 1fr) 5.5rem; align-items: center; gap: 0.35rem; padding: 0.35rem; border: 1px solid #e1e6ec; border-radius: 4px; font-size: 0.74rem; } +.voice-control label { display: inline-flex; align-items: center; gap: 0.15rem; margin: 0; font-size: 0.72rem; } +.voice-control input[type="range"] { min-width: 0; width: 100%; } +.voice-control select { min-width: 0; padding: 0.15rem; font-size: 0.72rem; } +[data-playback-active="true"] { fill: #b42318; font-weight: 700; } +.jpw7-print-root { display: none; } +@media print { + @page { size: A4 portrait; margin: 0; } + body > *:not(.jpw7-print-root) { display: none !important; } + .jpw7-print-root { display: block !important; } + .jpw7-print-page { width: 210mm; height: 297mm; break-after: page; page-break-after: always; } + .jpw7-print-page:last-child { break-after: auto; page-break-after: auto; } + .jpw7-print-page svg { display: block; width: 210mm; height: 297mm; } +} +@media (max-width: 1100px) { body { min-width: 0; } .workspace { grid-template-columns: 290px minmax(0, 1fr); } .paper-page { min-width: 480px; width: calc(100vw - 335px); } } diff --git a/src/domain/command-store.ts b/src/domain/command-store.ts new file mode 100644 index 0000000..f3279cb --- /dev/null +++ b/src/domain/command-store.ts @@ -0,0 +1,334 @@ +import { + cloneDocument, + lyricMetadataFromRaw, + resolveLyricSyllableEventIds, + modelEquivalent, + normalizeManualLayout, + normalizeAttachmentStatuses, + type AnchorRef, + type ScoreDocument, + type ScoreEvent, + type ScoreMeasure, + type StableId, + type ManualLayoutState, + type PageObjectModel, + type VoiceGroup, + type TitleBlock, + ScoreModel, + validateScore +} from "./score-model.js"; +import type { ModelDiagnostic } from "./score-model.js"; + +export type EventPatch = Partial>; + +export type VoiceLayoutPatch = Partial>; +export type MeasurePatch = Partial>; +export type PageObjectPatch = Partial>; + +export type EditCommand = + | { type: "insert-event"; voiceId: StableId; measureId: StableId; index?: number; event: ScoreEvent } + | { type: "update-event"; eventId: StableId; patch: EventPatch } + | { type: "update-measure"; measureId: StableId; patch: MeasurePatch } + | { type: "insert-measure"; voiceId: StableId; index: number; measure: ScoreMeasure } + | { type: "delete-measure"; voiceId: StableId; measureId: StableId } + | { type: "delete-event"; eventId: StableId } + | { type: "set-title"; field: keyof TitleBlock; value: string | number | null } + | { type: "set-document-option"; key: string; value: string } + | { type: "set-manual-layout"; patch: Partial } + | { type: "set-voice-layout"; voiceId: StableId; patch: VoiceLayoutPatch } + | { type: "add-temporary-voice"; voice: VoiceGroup } + | { type: "split-voice"; voiceId: StableId; atMeasure: number; newVoiceId: StableId; newName?: string } + | { type: "merge-voices"; targetVoiceId: StableId; sourceVoiceId: StableId; strategy?: "append" | "overlay" } + | { type: "delete-voice"; voiceId: StableId } + | { type: "insert-page-object"; object: PageObjectModel } + | { type: "update-page-object"; objectId: StableId; patch: PageObjectPatch } + | { type: "delete-page-object"; objectId: StableId } + | { type: "set-attachment-anchor"; attachmentId: StableId; anchor: AnchorRef } + | { type: "set-attachment-raw"; attachmentId: StableId; raw: string } + | { type: "delete-attachment"; attachmentId: StableId }; + +export class CommandRejectedError extends Error { + readonly diagnostics: ModelDiagnostic[]; + + constructor(message: string, diagnostics: ModelDiagnostic[] = []) { + super(message); + this.name = "CommandRejectedError"; + this.diagnostics = diagnostics; + } +} + +function findEvent(document: ScoreDocument, eventId: StableId): { event: ScoreEvent; events: ScoreEvent[] } | null { + for (const voice of document.voices) { + for (const measure of voice.measures) { + const event = measure.events.find((candidate) => candidate.id === eventId); + if (event) return { event, events: measure.events }; + } + } + return null; +} + +function findMeasure(document: ScoreDocument, voiceId: StableId, measureId: StableId) { + const voice = document.voices.find((candidate) => candidate.id === voiceId); + const measure = voice?.measures.find((candidate) => candidate.id === measureId); + return voice && measure ? { voice, measure } : null; +} + +function findMeasureById(document: ScoreDocument, measureId: StableId): ScoreMeasure | null { + for (const voice of document.voices) { + const measure = voice.measures.find((candidate) => candidate.id === measureId); + if (measure) return measure; + } + return null; +} + +function fail(message: string): never { + throw new CommandRejectedError(message); +} + +export function applyEditCommand(document: ScoreDocument, command: EditCommand): ScoreDocument { + const next = cloneDocument(document); + switch (command.type) { + case "insert-event": { + const target = findMeasure(next, command.voiceId, command.measureId); + if (!target) fail(`找不到目标声部/小节:${command.voiceId}/${command.measureId}`); + const index = command.index ?? target.measure.events.length; + if (!Number.isInteger(index) || index < 0 || index > target.measure.events.length) fail(`插入位置无效:${index}`); + target.measure.events.splice(index, 0, structuredClone(command.event)); + break; + } + case "update-event": { + const target = findEvent(next, command.eventId); + if (!target) fail(`找不到目标事件:${command.eventId}`); + Object.assign(target.event, structuredClone(command.patch)); + target.event.id = command.eventId; + break; + } + case "update-measure": { + const measure = findMeasureById(next, command.measureId); + if (!measure) fail(`找不到要编辑的小节:${command.measureId}`); + Object.assign(measure, structuredClone(command.patch)); + break; + } + case "insert-measure": { + const voice = next.voices.find((candidate) => candidate.id === command.voiceId); + if (!voice) fail(`找不到声部:${command.voiceId}`); + if (voice.measures.some((measure) => measure.id === command.measure.id)) fail(`小节 ID 已存在:${command.measure.id}`); + const index = Math.max(0, Math.min(command.index, voice.measures.length)); + voice.measures.splice(index, 0, structuredClone(command.measure)); + voice.measures.forEach((measure, ordinal) => { measure.ordinal = ordinal; }); + break; + } + case "delete-measure": { + const voice = next.voices.find((candidate) => candidate.id === command.voiceId); + if (!voice) fail(`找不到声部:${command.voiceId}`); + if (voice.measures.length <= 1) fail("至少保留一个小节。"); + const index = voice.measures.findIndex((measure) => measure.id === command.measureId); + if (index < 0) fail(`找不到要删除的小节:${command.measureId}`); + const eventIds = new Set(voice.measures[index].events.map((event) => event.id)); + voice.measures.splice(index, 1); + voice.measures.forEach((measure, ordinal) => { measure.ordinal = ordinal; }); + for (const attachment of next.attachments) { + if (attachment.anchor.startId && eventIds.has(attachment.anchor.startId)) attachment.anchor.startId = null; + if (attachment.anchor.endId && eventIds.has(attachment.anchor.endId)) attachment.anchor.endId = null; + } + break; + } + case "delete-event": { + const target = findEvent(next, command.eventId); + if (!target) fail(`找不到要删除的事件:${command.eventId}`); + target.events.splice(target.events.indexOf(target.event), 1); + for (const attachment of next.attachments) { + if (attachment.anchor.startId === command.eventId) attachment.anchor.startId = null; + if (attachment.anchor.endId === command.eventId) attachment.anchor.endId = null; + } + break; + } + case "set-title": + next.title[command.field] = command.value as never; + break; + case "set-document-option": + next.documentOptions[command.key] = command.value; + break; + case "set-manual-layout": { + const patch = structuredClone(command.patch); + next.manualLayout = normalizeManualLayout({ ...next.manualLayout, ...patch }); + for (const key of ["measureSpacing", "objectPositions", "measurePositions", "voiceSpacing", "voiceAlignment", "lyricOffsets", "fontOverrides"] as const) { + const value = patch[key]; + if (value && typeof value === "object") next.manualLayout[key] = { ...(next.manualLayout[key] ?? {}), ...value } as never; + } + break; + } + case "set-voice-layout": { + const voice = next.voices.find((candidate) => candidate.id === command.voiceId); + if (!voice) fail(`找不到声部:${command.voiceId}`); + Object.assign(voice, structuredClone(command.patch)); + break; + } + case "add-temporary-voice": { + if (!command.voice.temporary) fail("临时声部必须标记 temporary=true。"); + if (next.voices.some((voice) => voice.id === command.voice.id)) fail(`声部 ID 已存在:${command.voice.id}`); + next.voices.push(structuredClone(command.voice)); + break; + } + case "split-voice": { + const index = next.voices.findIndex((voice) => voice.id === command.voiceId); + if (index < 0) fail(`找不到要拆分的声部:${command.voiceId}`); + const voice = next.voices[index]; + if (!Number.isInteger(command.atMeasure) || command.atMeasure <= 0 || command.atMeasure >= voice.measures.length) fail("拆分位置必须位于声部的小节范围内部。"); + if (next.voices.some((candidate) => candidate.id === command.newVoiceId)) fail(`声部 ID 已存在:${command.newVoiceId}`); + const clone = structuredClone(voice); + voice.measures = clone.measures.slice(0, command.atMeasure); + const splitVoice: VoiceGroup = { ...clone, id: command.newVoiceId, name: command.newName ?? `${voice.name}(拆分)`, measures: clone.measures.slice(command.atMeasure), temporary: voice.temporary }; + next.voices.splice(index + 1, 0, splitVoice); + const order = [...(next.manualLayout.voiceOrder ?? next.voices.map((candidate) => candidate.id))]; + const orderIndex = order.indexOf(command.voiceId); + order.splice(orderIndex < 0 ? index + 1 : orderIndex + 1, 0, command.newVoiceId); + next.manualLayout.voiceOrder = order; + break; + } + case "merge-voices": { + const targetIndex = next.voices.findIndex((voice) => voice.id === command.targetVoiceId); + const sourceIndex = next.voices.findIndex((voice) => voice.id === command.sourceVoiceId); + if (targetIndex < 0 || sourceIndex < 0 || targetIndex === sourceIndex) fail("合并声部需要两个不同的有效声部。"); + const target = next.voices[targetIndex]; + const source = next.voices[sourceIndex]; + if (command.strategy === "overlay") { + const count = Math.min(target.measures.length, source.measures.length); + for (let index = 0; index < count; index += 1) target.measures[index].events.push(...structuredClone(source.measures[index].events)); + if (source.measures.length > count) target.measures.push(...structuredClone(source.measures.slice(count))); + } else target.measures.push(...structuredClone(source.measures)); + target.name = `${target.name}+${source.name}`; + next.voices.splice(sourceIndex, 1); + next.manualLayout.voiceOrder = next.manualLayout.voiceOrder?.filter((voiceId) => voiceId !== command.sourceVoiceId); + break; + } + case "delete-voice": { + const index = next.voices.findIndex((voice) => voice.id === command.voiceId); + if (index < 0) fail(`找不到声部:${command.voiceId}`); + if (!next.voices[index].temporary) fail("主声部不可通过临时声部命令删除。"); + const eventIds = new Set(next.voices[index].measures.flatMap((measure) => measure.events.map((event) => event.id))); + next.voices.splice(index, 1); + for (const attachment of next.attachments) { + if (attachment.anchor.startId && eventIds.has(attachment.anchor.startId)) attachment.anchor.startId = null; + if (attachment.anchor.endId && eventIds.has(attachment.anchor.endId)) attachment.anchor.endId = null; + } + next.manualLayout.voiceOrder = next.manualLayout.voiceOrder?.filter((voiceId) => voiceId !== command.voiceId); + break; + } + case "insert-page-object": { + if (next.pageObjects.some((object) => object.id === command.object.id)) fail(`页面对象 ID 已存在:${command.object.id}`); + next.pageObjects.push(structuredClone(command.object)); + break; + } + case "update-page-object": { + const object = next.pageObjects.find((candidate) => candidate.id === command.objectId); + if (!object) fail(`找不到页面对象:${command.objectId}`); + Object.assign(object, structuredClone(command.patch)); + break; + } + case "delete-page-object": { + const index = next.pageObjects.findIndex((candidate) => candidate.id === command.objectId); + if (index < 0) fail(`找不到页面对象:${command.objectId}`); + next.pageObjects.splice(index, 1); + break; + } + case "set-attachment-anchor": { + const attachment = next.attachments.find((candidate) => candidate.id === command.attachmentId); + if (!attachment) fail(`找不到附件:${command.attachmentId}`); + attachment.anchor = structuredClone(command.anchor); + if (attachment.kind === "Lyric" && attachment.lyric) attachment.lyric.syllableEventIds = resolveLyricSyllableEventIds(attachment.anchor, attachment.lyric.syllables, next.voices); + break; + } + case "set-attachment-raw": { + const attachment = next.attachments.find((candidate) => candidate.id === command.attachmentId); + if (!attachment) fail(`找不到附件:${command.attachmentId}`); + attachment.raw = command.raw; + if (attachment.kind === "Lyric") { + const lyric = lyricMetadataFromRaw(command.raw); + attachment.lyric = lyric ? { ...lyric, syllableEventIds: resolveLyricSyllableEventIds(attachment.anchor, lyric.syllables, next.voices) } : undefined; + } + break; + } + case "delete-attachment": { + const index = next.attachments.findIndex((candidate) => candidate.id === command.attachmentId); + if (index < 0) fail(`找不到附件:${command.attachmentId}`); + next.attachments.splice(index, 1); + break; + } + default: + fail("不支持的编辑命令"); + } + + const normalized = normalizeAttachmentStatuses(next); + const validation = validateScore(normalized); + if (!validation.valid) throw new CommandRejectedError("编辑命令被时值/引用校验拒绝。", validation.diagnostics); + return normalized; +} + +interface HistoryEntry { + command: EditCommand; + before: ScoreDocument; + after: ScoreDocument; +} + +export class CommandStore { + readonly model: ScoreModel; + private savedSnapshot: ScoreDocument; + private undoStack: HistoryEntry[] = []; + private redoStack: HistoryEntry[] = []; + + constructor(document: ScoreDocument) { + this.model = new ScoreModel(document); + this.savedSnapshot = this.model.snapshot(); + } + + get dirty(): boolean { + return !modelEquivalent(this.model.snapshot(), this.savedSnapshot); + } + + get canUndo(): boolean { + return this.undoStack.length > 0; + } + + get canRedo(): boolean { + return this.redoStack.length > 0; + } + + execute(command: EditCommand): ScoreDocument { + const before = this.model.snapshot(); + const after = applyEditCommand(before, command); + this.model.replace(after); + this.undoStack.push({ command, before, after: cloneDocument(after) }); + this.redoStack = []; + return this.model.snapshot(); + } + + undo(): ScoreDocument | null { + const entry = this.undoStack.pop(); + if (!entry) return null; + this.model.replace(entry.before); + this.redoStack.push(entry); + return this.model.snapshot(); + } + + redo(): ScoreDocument | null { + const entry = this.redoStack.pop(); + if (!entry) return null; + this.model.replace(entry.after); + this.undoStack.push(entry); + return this.model.snapshot(); + } + + markSaved(): void { + this.savedSnapshot = this.model.snapshot(); + } + + snapshot(): ScoreDocument { + return this.model.snapshot(); + } + + clearHistory(): void { + this.undoStack = []; + this.redoStack = []; + } +} diff --git a/src/domain/jpwabc-worker.ts b/src/domain/jpwabc-worker.ts new file mode 100644 index 0000000..292e64d --- /dev/null +++ b/src/domain/jpwabc-worker.ts @@ -0,0 +1,34 @@ +import { parseJpwabc, type ParseResult } from "./jpwabc.js"; +import { modelFromParse, type ScoreDocument } from "./score-model.js"; + +interface ImportRequest { + bytes: ArrayBuffer; + fileName: string; +} + +interface ImportResponse { + parsed: ParseResult; + model: ScoreDocument; +} + +const workerScope = globalThis as typeof globalThis & { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: ImportResponse | { error: string }) => void; +}; + +async function sha256Hex(bytes: ArrayBuffer): Promise { + if (!globalThis.crypto?.subtle) return null; + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +workerScope.onmessage = async (event) => { + try { + const bytes = event.data.bytes; + const parsed = parseJpwabc(new Uint8Array(bytes)); + const model = modelFromParse(parsed, { fileName: event.data.fileName, sourceSha256: await sha256Hex(bytes) }); + workerScope.postMessage({ parsed, model }); + } catch (error) { + workerScope.postMessage({ error: error instanceof Error ? error.message : "导入失败" }); + } +}; diff --git a/src/domain/jpwabc-writer.ts b/src/domain/jpwabc-writer.ts new file mode 100644 index 0000000..ead0d13 --- /dev/null +++ b/src/domain/jpwabc-writer.ts @@ -0,0 +1,176 @@ +import { encodeSource } from "./jpwabc.js"; +import type { ScoreDocument, ScoreEvent, StableId } from "./score-model.js"; + +export interface CompatibilityPreflight { + safe: boolean; + reasons: string[]; + warnings: string[]; + externalReplayRequired: true; + supportedSections: string[]; +} + +export interface JpwabcExport { + text: string; + bytes: Uint8Array; + report: CompatibilityPreflight; +} + +export class CompatibilityExportBlockedError extends Error { + readonly report: CompatibilityPreflight; + + constructor(report: CompatibilityPreflight) { + super(`兼容导出已阻止:${report.reasons.join(";")}`); + this.name = "CompatibilityExportBlockedError"; + this.report = report; + } +} + +const WRITABLE_OPTIONS = new Set([ + "Accomp", "Arc", "Digit", "DrumChar", "FitFactor", "HorzSpacing_AW", "HorzSpacing_Gap", "Lyric", "Paper", "VertSpacing" +]); + +const WRITABLE_FONT_ROLES = new Set([ + "Default", "Intro", "Title", "SubTitle", "SubTitle2", "WordsByAndMusicBy", "KeyAndMeters", "Expression", "Lyric", + "LyricPrefix", "LyricVerseIndex", "LyricExtra1", "LyricExtra2", "DrumPitch", "AuxInfo", "Text", "FangZi", + "AttachText1", "AttachText2", "AttachText3", "AttachText4", "PartName", "Memo1", "Memo2", "Memo3", "Memo4", "WaterMark" +]); + +function compact(value: string): string { + return value.replace(/[\r\n]+/g, " ").trim(); +} + +function eventDurationSuffix(event: ScoreEvent): string | null { + const duration = event.duration; + if (!duration || duration.numerator <= 0) return null; + const value = duration.numerator / duration.denominator; + if (value === 1) return ""; + if (value === 0.5) return "_"; + if (value === 0.25) return "__"; + if (value === 0.125) return "___"; + if (value === 0.0625) return "____"; + // An increase line adds one base unit; e.g. 1- is two beats and 1-- is three. + if (Number.isInteger(value) && value >= 2 && value <= 5) return "-".repeat(value - 1); + // Dotted values are part of the core syntax, but only a single dot is + // emitted here. Other ratios remain in the project and block this export. + if (value === 1.5) return "."; + if (value === 0.75) return "_."; + if (value === 0.375) return "__."; + return null; +} + +function renderEvent(event: ScoreEvent): string | null { + const suffix = eventDurationSuffix(event); + if (suffix === null) return null; + if (event.kind === "rest") return `0${suffix}`; + if (event.kind !== "note" || !event.pitch || event.pitch.degree === null) return null; + const accidental = event.pitch.accidental < 0 ? "b" : event.pitch.accidental > 0 ? "#" : ""; + const octave = event.pitch.octave ?? 0; + const octaveMarks = octave > 0 ? "g".repeat(Math.min(4, octave)) : "d".repeat(Math.min(4, -octave)); + return `${accidental}${event.pitch.degree}${octaveMarks}${suffix}`; +} + +function renderVoice(document: ScoreDocument, voiceId: StableId): string | null { + const voice = document.voices.find((candidate) => candidate.id === voiceId); + if (!voice) return null; + const measures: string[] = []; + for (const measure of voice.measures) { + const events: string[] = []; + for (const event of measure.events) { + const rendered = renderEvent(event); + if (rendered === null) return null; + events.push(rendered); + } + measures.push(events.join(" ")); + } + const body = measures.join(" | "); + return `${body}${body ? " " : ""}:| $`; +} + +function renderTitle(document: ScoreDocument): string[] { + const title = document.title; + const keyAndMeter = [title.key ? `1=${compact(title.key)}` : "", title.meter ? compact(title.meter) : ""].filter(Boolean).join(","); + const expression = [title.tempo ? `{J=${title.tempo}}` : "", compact(title.expression)].filter(Boolean).join(" "); + return [ + `Intro = ${compact(title.title)}`, + `Title = ${compact(title.title)}`, + "SubTitle = ", + "SubTitle2 = ", + `KeyAndMeters = ${keyAndMeter ? `{${keyAndMeter}}` : ""}`, + `WordsByAndMusicBy = ${compact(title.composer)}`, + `Expression = ${expression}`, + "LinePos = 0.0, 0.0, 16.0, 20.0, 16.0, 16.0, 20.0" + ]; +} + +function renderLyric(raw: string): string[] { + const lines = raw.replace(/\r\n/g, "\n").split("\n"); + return lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines; +} + +export function preflightJpwabcExport(document: ScoreDocument): CompatibilityPreflight { + const reasons: string[] = []; + const warnings: string[] = ["此写出支持集仍必须由固定 JPW7 v7.30 原程序独立回验;Web 自我再导入不构成 ACC-X-012 证据。"]; + if (document.unknownBlocks.length) reasons.push("存在未知/损坏原文区块,完整工程会保留但安全兼容写出会阻止"); + const unknownEvents = document.voices.flatMap((voice) => voice.measures.flatMap((measure) => measure.events.filter((event) => !["note", "rest"].includes(event.kind)))); + if (unknownEvents.length) reasons.push(`存在 ${unknownEvents.length} 个非核心声部事件,无法安全映射到核心写出集`); + if (document.pageObjects.length) reasons.push("页面对象属于尚未验证的 .Page 写入支持集"); + const unsupportedAttachments = document.attachments.filter((attachment) => attachment.kind !== "Lyric"); + if (unsupportedAttachments.length) reasons.push("存在尚未验证的附件类型,不能静默丢弃"); + const unresolvedLyrics = document.attachments.filter((attachment) => attachment.kind === "Lyric" && attachment.status !== "attached"); + if (unresolvedLyrics.length) reasons.push("歌词锚点未决/孤立,不能安全写回 .Words"); + const unknownOptions = Object.keys(document.documentOptions).filter((key) => !WRITABLE_OPTIONS.has(key)); + if (unknownOptions.length) reasons.push(`存在未验证文档选项:${unknownOptions.join(", ")}`); + const unknownFonts = Object.keys(document.fontRoles).filter((key) => !WRITABLE_FONT_ROLES.has(key)); + if (unknownFonts.length) reasons.push(`存在未验证字体角色:${unknownFonts.join(", ")}`); + if (document.manualLayout.lockedMeasureIds.length || document.manualLayout.forcedBreakAfterMeasureIds.length || Object.keys(document.manualLayout.measureSpacing).length || Object.keys(document.manualLayout.objectPositions).length || Object.keys(document.manualLayout.measurePositions ?? {}).length || Object.keys(document.manualLayout.voiceSpacing ?? {}).length || Object.keys(document.manualLayout.lyricOffsets ?? {}).length || Object.keys(document.manualLayout.fontOverrides ?? {}).length || (document.manualLayout.voiceOrder?.length ?? 0) || Object.keys(document.manualLayout.voiceAlignment ?? {}).length) { + reasons.push("工程含尚未映射的 Web 手动排版状态;请保存工程,不能静默当作 JPW 版式"); + } + if (document.voices.some((voice) => voice.temporary || voice.alignment || voice.fontRole)) reasons.push("工程含临时/扩展声部布局状态,不能安全映射到核心 JPW-ABC 声部"); + for (const voice of document.voices) { + for (const measure of voice.measures) { + for (const event of measure.events) if (event.kind === "note" || event.kind === "rest") { + if (eventDurationSuffix(event) === null) reasons.push(`事件 ${event.id} 的分数时值不在 M5 核心写出支持集`); + } + } + } + return { + safe: reasons.length === 0, + reasons, + warnings, + externalReplayRequired: true, + supportedSections: [".Options", ".Fonts", ".Title", ".Voice", ".Words"] + }; +} + +/** Write only the allow-listed core JPW-ABC sections. */ +export function serializeJpwabc(document: ScoreDocument): JpwabcExport { + const report = preflightJpwabcExport(document); + if (!report.safe) throw new CompatibilityExportBlockedError(report); + const lines: string[] = [ + "// ************** JPW-ABC File Ver 1.0 (for JP-Word v7.30) **************(JP-Word v5)", + "", + ".Options" + ]; + for (const [key, value] of Object.entries(document.documentOptions)) lines.push(`${key} = ${compact(value)}`); + lines.push("", ".Fonts"); + for (const [role, family] of Object.entries(document.fontRoles)) lines.push(`${role} = ${compact(family)}, 3.50`); + lines.push("", ".Title", ...renderTitle(document), "", ".Voice"); + for (const voice of document.voices) { + const line = renderVoice(document, voice.id); + if (line === null) throw new CompatibilityExportBlockedError({ ...report, safe: false, reasons: [`声部 ${voice.id} 无法编码为核心 JPW-ABC`, ...report.reasons] }); + lines.push(line); + } + if (!document.voices.length) lines.push("$ "); + const lyrics = document.attachments.filter((attachment) => attachment.kind === "Lyric"); + if (lyrics.length) { + lines.push("", ".Words"); + for (const lyric of lyrics) lines.push(...renderLyric(lyric.raw)); + } + const text = `${lines.join("\r\n")}\r\n`; + const encoding = document.source?.encoding === "utf-16be" ? "utf-16be" : "utf-16le"; + const bom = document.source?.bom === "utf-16be" ? "utf-16be" : "utf-16le"; + return { text, bytes: encodeSource(text, encoding, bom), report }; +} + +export const writeJpwabc = serializeJpwabc; +export const compatibilityPreflight = preflightJpwabcExport; diff --git a/src/domain/jpwabc.ts b/src/domain/jpwabc.ts new file mode 100644 index 0000000..71c1578 --- /dev/null +++ b/src/domain/jpwabc.ts @@ -0,0 +1,608 @@ +export type TextEncoding = "utf-16le" | "utf-16be" | "utf-8" | "unknown"; +export type BomKind = "utf-16le" | "utf-16be" | "utf-8" | "none"; +export type DiagnosticSeverity = "error" | "warning" | "info"; + +export interface SourceRange { + start: number; + end: number; + line: number; + column: number; + endLine: number; + endColumn: number; + byteStart: number; + byteEnd: number; +} + +export interface Diagnostic { + code: string; + severity: DiagnosticSeverity; + message: string; + range: SourceRange | null; + recovery: "continue" | "preserve" | "block-export"; +} + +export interface SourceLine { + text: string; + ending: string; + range: SourceRange; + fullRange: SourceRange; +} + +export interface SourceDocument { + text: string; + bytes: Uint8Array; + encoding: TextEncoding; + bom: BomKind; + bomBytes: number; + lines: SourceLine[]; +} + +export interface SourceSection { + id: string; + name: string; + known: boolean; + header: SourceLine | null; + lines: SourceLine[]; + range: SourceRange; +} + +export interface HeaderInfo { + formatVersion: string | null; + productVersion: string | null; + raw: string; +} + +export interface RawToken { + raw: string; + head: string; + known: boolean; + range: SourceRange; + context: "voice" | "words" | "attachment" | "title" | "page" | "unknown"; +} + +export interface UnknownBlock { + id: string; + reason: "unknown-section" | "unknown-token" | "orphan-line" | "malformed-line"; + rawText: string; + range: SourceRange; + context: string; +} + +export interface AssignmentEntry { + key: string; + value: string; + raw: string; + range: SourceRange; +} + +export interface OptionLayers { + application: AssignmentEntry[]; + document: AssignmentEntry[]; + unknownDocument: AssignmentEntry[]; +} + +export interface FontEntry extends AssignmentEntry { + role: string; + family: string | null; + size: number | null; + flags: string[]; +} + +export interface TitleEntry extends AssignmentEntry { + field: string; +} + +export type VoiceTokenKind = "note" | "rest" | "barline" | "time-signature" | "brace" | "text" | "terminator" | "unknown"; + +export interface VoiceToken { + raw: string; + kind: VoiceTokenKind; + range: SourceRange; + durationMarks: number; +} + +export interface VoiceLine { + raw: string; + range: SourceRange; + isComment: boolean; + isQuoted: boolean; + tokens: VoiceToken[]; +} + +export interface WordBlock { + header: string; + rawLines: string[]; + range: SourceRange; + bodyRange: SourceRange | null; + tokens: RawToken[]; +} + +export interface ObjectLine { + kind: string; + anchor: string; + payload: string; + raw: string; + range: SourceRange; + tokens: RawToken[]; +} + +export interface CompatibilityReport { + projectSave: "safe" | "blocked"; + originalExport: "safe" | "blocked"; + reasons: string[]; + requiresExternalReplay: boolean; +} + +export interface ParseResult { + source: SourceDocument; + header: HeaderInfo; + sections: SourceSection[]; + options: OptionLayers; + fonts: FontEntry[]; + titles: TitleEntry[]; + voices: VoiceLine[]; + words: WordBlock[]; + attachments: ObjectLine[]; + pages: ObjectLine[]; + rawTokens: RawToken[]; + unknownTokens: RawToken[]; + unknownBlocks: UnknownBlock[]; + diagnostics: Diagnostic[]; + compatibility: CompatibilityReport; +} + +const KNOWN_SECTIONS = new Set([".Options", ".Fonts", ".Title", ".Voice", ".Words", ".Attachments", ".Page"]); +const KNOWN_ATTACHMENT_TYPES = new Set(["Text", "Extend", "Often", "Brace", "Branch", "Arc", "FangZi", "Dynamic"]); +const KNOWN_OPTION_KEYS = new Set([ + "Accomp", "Arc", "Digit", "DrumChar", "FitFactor", "HorzSpacing_AW", "HorzSpacing_Gap", "Lyric", "Paper", "VertSpacing" +]); +const KNOWN_FONT_ROLES = new Set([ + "Default", "Intro", "Title", "SubTitle", "SubTitle2", "WordsByAndMusicBy", "KeyAndMeters", "Expression", "Lyric", + "LyricPrefix", "LyricVerseIndex", "LyricExtra1", "LyricExtra2", "DrumPitch", "AuxInfo", "Text", "FangZi", + "AttachText1", "AttachText2", "AttachText3", "AttachText4", "PartName", "Memo1", "Memo2", "Memo3", "Memo4", "WaterMark" +]); + +const bytesFrom = (input: Uint8Array | ArrayBuffer): Uint8Array => input instanceof Uint8Array ? input : new Uint8Array(input); +const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => left.length === right.length && left.every((value, index) => value === right[index]); + +function detectEncoding(bytes: Uint8Array): { encoding: TextEncoding; bom: BomKind; bomBytes: number } { + if (bytes[0] === 0xff && bytes[1] === 0xfe) return { encoding: "utf-16le", bom: "utf-16le", bomBytes: 2 }; + if (bytes[0] === 0xfe && bytes[1] === 0xff) return { encoding: "utf-16be", bom: "utf-16be", bomBytes: 2 }; + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return { encoding: "utf-8", bom: "utf-8", bomBytes: 3 }; + if (bytes.length > 1 && bytes[1] === 0) return { encoding: "utf-16le", bom: "none", bomBytes: 0 }; + if (bytes.length > 1 && bytes[0] === 0) return { encoding: "utf-16be", bom: "none", bomBytes: 0 }; + return { encoding: "utf-8", bom: "none", bomBytes: 0 }; +} + +function encodeUtf16(text: string, littleEndian: boolean): Uint8Array { + const result = new Uint8Array(text.length * 2); + for (let index = 0; index < text.length; index += 1) { + const codeUnit = text.charCodeAt(index); + if (littleEndian) { + result[index * 2] = codeUnit & 0xff; + result[index * 2 + 1] = codeUnit >>> 8; + } else { + result[index * 2] = codeUnit >>> 8; + result[index * 2 + 1] = codeUnit & 0xff; + } + } + return result; +} + +export function encodeSource(text: string, encoding: TextEncoding, bom: BomKind): Uint8Array { + const body = encoding === "utf-16le" + ? encodeUtf16(text, true) + : encoding === "utf-16be" + ? encodeUtf16(text, false) + : new TextEncoder().encode(text); + const prefix = bom === "utf-16le" ? [0xff, 0xfe] : bom === "utf-16be" ? [0xfe, 0xff] : bom === "utf-8" ? [0xef, 0xbb, 0xbf] : []; + const result = new Uint8Array(prefix.length + body.length); + result.set(prefix, 0); + result.set(body, prefix.length); + return result; +} + +function byteOffset(text: string, index: number, encoding: TextEncoding, bomBytes: number): number { + if (encoding === "utf-16le" || encoding === "utf-16be") return bomBytes + index * 2; + return bomBytes + new TextEncoder().encode(text.slice(0, index)).length; +} + +function rangeFor(text: string, start: number, end: number, line: number, column: number, encoding: TextEncoding, bomBytes: number): SourceRange { + const endColumn = column + Math.max(0, end - start); + return { + start, + end, + line, + column, + endLine: line, + endColumn, + byteStart: byteOffset(text, start, encoding, bomBytes), + byteEnd: byteOffset(text, end, encoding, bomBytes) + }; +} + +function splitLines(text: string, encoding: TextEncoding, bomBytes: number): SourceLine[] { + const lines: SourceLine[] = []; + let lineStart = 0; + let lineNumber = 1; + for (let index = 0; index <= text.length; index += 1) { + const atEnd = index === text.length; + const isBreak = !atEnd && (text[index] === "\n" || text[index] === "\r"); + if (!atEnd && !isBreak) continue; + const contentEnd = index; + let ending = ""; + if (!atEnd) { + if (text[index] === "\r" && text[index + 1] === "\n") { + ending = "\r\n"; + index += 1; + } else { + ending = text[index]; + } + } + const fullEnd = contentEnd + ending.length; + lines.push({ + text: text.slice(lineStart, contentEnd), + ending, + range: rangeFor(text, lineStart, contentEnd, lineNumber, 1, encoding, bomBytes), + fullRange: rangeFor(text, lineStart, fullEnd, lineNumber, 1, encoding, bomBytes) + }); + lineNumber += 1; + lineStart = fullEnd; + } + if (lines.length === 0) lines.push({ text: "", ending: "", range: rangeFor(text, 0, 0, 1, 1, encoding, bomBytes), fullRange: rangeFor(text, 0, 0, 1, 1, encoding, bomBytes) }); + return lines; +} + +function diagnostic(code: string, severity: DiagnosticSeverity, message: string, range: SourceRange | null, recovery: Diagnostic["recovery"]): Diagnostic { + return { code, severity, message, range, recovery }; +} + +function scanBrace(text: string, start: number): { end: number; raw: string } | null { + let depth = 0; + let quote = false; + let escaped = false; + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (quote) { + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') quote = false; + continue; + } + if (char === '"') { + quote = true; + continue; + } + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return { end: index + 1, raw: text.slice(start, index + 1) }; + } + } + return null; +} + +function scanQuoted(text: string, start: number): number | null { + let escaped = false; + for (let index = start + 1; index < text.length; index += 1) { + const char = text[index]; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') return index + 1; + } + return null; +} + +function tokenHead(raw: string): string { + const body = raw.replace(/^\{/, "").replace(/\}$/, "").trim(); + return body.split(/[;,:=@|\s]/, 1)[0] || ""; +} + +function knownBraceToken(head: string): boolean { + return /^(?:C|cpo|hpf|wlv|gl[hv]|bny|rct|DunYin|BoYin|YanYin|ZhongYin|HuanQi|Force-p|PR|PV|[A-Z]|[0-7]|[b#]?[0-7]|[([]|\\xF[0-9A-Fa-f]{3,4})$/.test(head); +} + +function scanTokens(text: string, line: SourceLine, encoding: TextEncoding, bomBytes: number, context: RawToken["context"], diagnostics: Diagnostic[]): RawToken[] { + const tokens: RawToken[] = []; + let index = 0; + while (index < text.length) { + if (text[index] === "{") { + const found = scanBrace(text, index); + if (!found) { + diagnostics.push(diagnostic("UNCLOSED_BRACE", "error", "未闭合的花括号结构已保留原文并阻止安全兼容导出。", rangeFor(text, index, text.length, line.range.line, index + 1, encoding, bomBytes), "block-export")); + break; + } + const head = tokenHead(found.raw); + const range = rangeFor(text, index, found.end, line.range.line, index + 1, encoding, bomBytes); + tokens.push({ raw: found.raw, head, known: knownBraceToken(head), range, context }); + index = found.end; + continue; + } + if (text[index] === '"') { + const end = scanQuoted(text, index); + if (end === null) { + diagnostics.push(diagnostic("UNCLOSED_QUOTE", "error", "未闭合的引号结构已保留原文并阻止安全兼容导出。", rangeFor(text, index, text.length, line.range.line, index + 1, encoding, bomBytes), "block-export")); + break; + } + index = end; + continue; + } + index += 1; + } + return tokens; +} + +function parseAssignment(line: SourceLine): AssignmentEntry | null { + const match = line.text.match(/^\s*([^=\s]+)\s*=\s*(.*)$/); + if (!match) return null; + return { key: match[1], value: match[2], raw: line.text, range: line.range }; +} + +function parseFont(entry: AssignmentEntry): FontEntry { + const pieces = entry.value.split(",").map((piece) => piece.trim()); + const size = pieces[1] && Number.isFinite(Number(pieces[1])) ? Number(pieces[1]) : null; + return { ...entry, role: entry.key, family: pieces[0] || null, size, flags: pieces.slice(2).filter(Boolean) }; +} + +function makeSourceDocument(bytes: Uint8Array, diagnostics: Diagnostic[]): SourceDocument { + const detected = detectEncoding(bytes); + let text = ""; + try { + const decoder = new TextDecoder(detected.encoding === "unknown" ? "utf-8" : detected.encoding, { fatal: true }); + text = decoder.decode(bytes.slice(detected.bomBytes)); + } catch { + diagnostics.push(diagnostic("INVALID_ENCODING", "error", "文件编码无法按检测结果解码;已使用替代字符继续保留原文范围。", null, "block-export")); + text = new TextDecoder("utf-8").decode(bytes.slice(detected.bomBytes)); + } + return { text, bytes: new Uint8Array(bytes), ...detected, lines: splitLines(text, detected.encoding, detected.bomBytes) }; +} + +function sectionize(source: SourceDocument, diagnostics: Diagnostic[], unknownBlocks: UnknownBlock[]): SourceSection[] { + const sections: SourceSection[] = []; + let current: SourceSection = { + id: "section-0", + name: "", + known: true, + header: null, + lines: [], + range: source.lines[0]?.fullRange ?? rangeFor(source.text, 0, 0, 1, 1, source.encoding, source.bomBytes) + }; + const close = (end: SourceRange) => { current.range = { ...current.range, end: end.end, endLine: end.endLine, endColumn: end.endColumn, byteEnd: end.byteEnd }; sections.push(current); }; + for (const line of source.lines) { + const header = line.text.trim(); + if (header.startsWith(".") && !header.startsWith("..")) { + close(line.range); + const known = KNOWN_SECTIONS.has(header); + current = { + id: `section-${sections.length}`, + name: header, + known, + header: line, + lines: [], + range: line.fullRange + }; + if (!known) { + const block = { id: `unknown-section-${sections.length}`, reason: "unknown-section" as const, rawText: line.text, range: line.range, context: header }; + unknownBlocks.push(block); + diagnostics.push(diagnostic("UNKNOWN_SECTION", "warning", `未识别区块 ${header} 已保留。`, line.range, "preserve")); + } + continue; + } + current.lines.push(line); + } + close(source.lines[source.lines.length - 1]?.fullRange ?? current.range); + return sections; +} + +function voiceTokens(line: SourceLine, source: SourceDocument, rawTokens: RawToken[], unknownTokens: RawToken[], diagnostics: Diagnostic[]): VoiceToken[] { + const tokens: VoiceToken[] = []; + let start = 0; + const flush = (end: number) => { + if (end <= start) return; + const raw = line.text.slice(start, end); + const trimmed = raw.trim(); + if (!trimmed) return; + const leading = raw.indexOf(trimmed); + const tokenStart = line.range.start + start + leading; + const range = rangeFor(source.text, tokenStart, tokenStart + trimmed.length, line.range.line, line.range.column + start + leading, source.encoding, source.bomBytes); + const kind: VoiceTokenKind = trimmed === "$" ? "terminator" + : /^(?:\|\||\|:|:\||::|\|)$/.test(trimmed) ? "barline" + : /^\d+\/\d+$/.test(trimmed) ? "time-signature" + : /^\{/.test(trimmed) ? "brace" + : /^\"/.test(trimmed) ? "text" + : /^(?:[b#]?[0-7Xx]|\[[^\]]+\])(?:[._-]*|\.{1,3})/.test(trimmed) || /^(?:[b#]?[0-7Xx]|\[[^\]]+\])$/.test(trimmed) ? (trimmed.startsWith("0") ? "rest" : "note") + : "unknown"; + const brace = trimmed.startsWith("{") ? scanBrace(trimmed, 0) : null; + const rawToken: RawToken = { raw: trimmed, head: brace ? tokenHead(trimmed) : trimmed.slice(0, 40), known: kind !== "unknown", range, context: "voice" }; + rawTokens.push(rawToken); + if (kind === "unknown") { + unknownTokens.push(rawToken); + diagnostics.push(diagnostic("UNKNOWN_VOICE_TOKEN", "warning", `未分类的声部 token 已原样保留:${trimmed.slice(0, 40)}`, range, "preserve")); + } + tokens.push({ raw: trimmed, kind, range, durationMarks: (trimmed.match(/_/g) ?? []).length }); + }; + for (let index = 0; index < line.text.length; index += 1) { + const char = line.text[index]; + if (/\s/.test(char)) { flush(index); start = index + 1; continue; } + if (char === "{") { + const found = scanBrace(line.text, index); + if (found) { flush(index); const rawStart = index; const range = rangeFor(source.text, line.range.start + rawStart, line.range.start + found.end, line.range.line, rawStart + 1, source.encoding, source.bomBytes); const rawToken: RawToken = { raw: found.raw, head: tokenHead(found.raw), known: knownBraceToken(tokenHead(found.raw)), range, context: "voice" }; rawTokens.push(rawToken); if (!rawToken.known) unknownTokens.push(rawToken); tokens.push({ raw: found.raw, kind: "brace", range, durationMarks: 0 }); index = found.end - 1; start = found.end; continue; } + flush(index); + const range = rangeFor(source.text, line.range.start + index, line.range.start + line.text.length, line.range.line, index + 1, source.encoding, source.bomBytes); + const raw = line.text.slice(index); + const rawToken: RawToken = { raw, head: tokenHead(raw), known: false, range, context: "voice" }; + rawTokens.push(rawToken); + unknownTokens.push(rawToken); + diagnostics.push(diagnostic("UNCLOSED_BRACE", "error", "未闭合的花括号结构已保留原文并阻止安全兼容导出。", range, "block-export")); + tokens.push({ raw, kind: "brace", range, durationMarks: 0 }); + start = line.text.length; + break; + } + if (char === "|" || char === ":" && (line.text[index + 1] === "|" || line.text[index + 1] === ":" || line.text[index - 1] === "|")) { + flush(index); + const end = (char === "|" && (line.text[index + 1] === "|" || line.text[index + 1] === ":")) || (char === ":" && (line.text[index + 1] === "|" || line.text[index + 1] === ":")) ? index + 2 : index + 1; + const rawStart = index; + const raw = line.text.slice(rawStart, end); + const range = rangeFor(source.text, line.range.start + rawStart, line.range.start + end, line.range.line, rawStart + 1, source.encoding, source.bomBytes); + tokens.push({ raw, kind: "barline", range, durationMarks: 0 }); + rawTokens.push({ raw, head: raw, known: true, range, context: "voice" }); + index = end - 1; start = end; + } + } + flush(line.text.length); + return tokens; +} + +function objectLine(line: SourceLine, source: SourceDocument, context: RawToken["context"], rawTokens: RawToken[], unknownTokens: RawToken[], diagnostics: Diagnostic[]): ObjectLine | null { + const match = line.text.match(/^\s*([A-Za-z][\w-]*)@([^=]+?)\s*=\s*(.*)$/); + if (!match) return null; + const tokens = scanTokens(line.text, line, source.encoding, source.bomBytes, context, diagnostics); + rawTokens.push(...tokens); + unknownTokens.push(...tokens.filter((token) => !token.known)); + const kind = match[1]; + if (context === "attachment" && !KNOWN_ATTACHMENT_TYPES.has(kind)) diagnostics.push(diagnostic("UNKNOWN_ATTACHMENT", "warning", `未识别附件类型 ${kind} 已保留。`, line.range, "preserve")); + return { kind, anchor: match[2].trim(), payload: match[3], raw: line.text, range: line.range, tokens }; +} + +export function parseJpwabc(input: Uint8Array | ArrayBuffer): ParseResult { + const initialDiagnostics: Diagnostic[] = []; + const source = makeSourceDocument(bytesFrom(input), initialDiagnostics); + const diagnostics = [...initialDiagnostics]; + const unknownBlocks: UnknownBlock[] = []; + const rawTokens: RawToken[] = []; + const unknownTokens: RawToken[] = []; + const sections = sectionize(source, diagnostics, unknownBlocks); + const firstLine = source.lines.find((line) => line.text.trim().length > 0); + const headerText = firstLine?.text ?? ""; + const headerMatch = headerText.match(/JPW-ABC\s+File\s+Ver\s+([\d.]+)/i); + const productMatch = headerText.match(/JP-Word\s+v([\d.]+)/i); + const header: HeaderInfo = { formatVersion: headerMatch?.[1] ?? null, productVersion: productMatch?.[1] ?? null, raw: headerText }; + if (!headerMatch) diagnostics.push(diagnostic("MISSING_VERSION", "warning", "未找到 JPW-ABC 版本头;未知版本字段按原文保留。", firstLine?.range ?? null, "block-export")); + + const options: OptionLayers = { application: [], document: [], unknownDocument: [] }; + const fonts: FontEntry[] = []; + const titles: TitleEntry[] = []; + const voices: VoiceLine[] = []; + const words: WordBlock[] = []; + const attachments: ObjectLine[] = []; + const pages: ObjectLine[] = []; + + for (const section of sections) { + if (section.name === ".Options" || section.name === ".Fonts" || section.name === ".Title") { + for (const line of section.lines) { + if (!line.text.trim() || line.text.trim().startsWith("//")) continue; + const assignment = parseAssignment(line); + if (!assignment) { + if (line.text.trim()) { + unknownBlocks.push({ id: `malformed-${unknownBlocks.length}`, reason: "malformed-line", rawText: line.text, range: line.range, context: section.name }); + diagnostics.push(diagnostic("MALFORMED_ASSIGNMENT", "warning", `${section.name} 中无法解析的行已保留。`, line.range, "preserve")); + } + continue; + } + if (section.name === ".Options") { + options.document.push(assignment); + if (!KNOWN_OPTION_KEYS.has(assignment.key)) { + options.unknownDocument.push(assignment); + diagnostics.push(diagnostic("UNKNOWN_OPTION", "warning", `未识别文档选项 ${assignment.key} 已保留。`, assignment.range, "preserve")); + } + } else if (section.name === ".Fonts") { + const font = parseFont(assignment); + fonts.push(font); + if (!KNOWN_FONT_ROLES.has(font.role)) diagnostics.push(diagnostic("UNKNOWN_FONT_ROLE", "warning", `未识别字体角色 ${font.role} 已保留。`, font.range, "preserve")); + } else { + titles.push({ ...assignment, field: assignment.key }); + const tokens = scanTokens(assignment.value, line, source.encoding, source.bomBytes, "title", diagnostics); + rawTokens.push(...tokens); + unknownTokens.push(...tokens.filter((token) => !token.known)); + } + } + } else if (section.name === ".Voice") { + for (const line of section.lines) { + if (!line.text.trim()) continue; + const isComment = line.text.trim() === "$" || line.text.trim().startsWith("$"); + const isQuoted = line.text.trim().startsWith('"'); + voices.push({ raw: line.text, range: line.range, isComment, isQuoted, tokens: voiceTokens(line, source, rawTokens, unknownTokens, diagnostics) }); + } + } else if (section.name === ".Words") { + let current: WordBlock | null = null; + for (const line of section.lines) { + if (!line.text.trim()) continue; + const headerMatchWords = line.text.match(/^\s*(W[^:]*):\s*$/); + if (headerMatchWords) { + current = { header: headerMatchWords[1], rawLines: [], range: line.range, bodyRange: null, tokens: [] }; + words.push(current); + continue; + } + const tokens = scanTokens(line.text, line, source.encoding, source.bomBytes, "words", diagnostics); + rawTokens.push(...tokens); + unknownTokens.push(...tokens.filter((token) => !token.known)); + if (!current) { + unknownBlocks.push({ id: `orphan-word-${unknownBlocks.length}`, reason: "orphan-line", rawText: line.text, range: line.range, context: ".Words" }); + diagnostics.push(diagnostic("ORPHAN_WORD_LINE", "warning", "歌词正文在首个歌词头之前,已保留。", line.range, "preserve")); + continue; + } + current.rawLines.push(line.text); + current.bodyRange = current.bodyRange ? { ...current.bodyRange, end: line.range.end, endLine: line.range.endLine, endColumn: line.range.endColumn, byteEnd: line.range.byteEnd } : line.range; + current.tokens.push(...tokens); + current.range = { ...current.range, end: line.range.end, endLine: line.range.endLine, endColumn: line.range.endColumn, byteEnd: line.range.byteEnd }; + } + } else if (section.name === ".Attachments" || section.name === ".Page") { + for (const line of section.lines) { + if (!line.text.trim()) continue; + const result = objectLine(line, source, section.name === ".Attachments" ? "attachment" : "page", rawTokens, unknownTokens, diagnostics); + if (result) (section.name === ".Attachments" ? attachments : pages).push(result); + else { + unknownBlocks.push({ id: `malformed-${unknownBlocks.length}`, reason: "malformed-line", rawText: line.text, range: line.range, context: section.name }); + diagnostics.push(diagnostic("MALFORMED_OBJECT_LINE", "warning", `${section.name} 中无法解析的对象行已保留。`, line.range, "preserve")); + } + } + } + } + + const reasons: string[] = []; + if (diagnostics.some((item) => item.code === "INVALID_ENCODING" || item.code === "UNCLOSED_BRACE" || item.code === "UNCLOSED_QUOTE")) reasons.push("fatal-source-diagnostic"); + if (unknownBlocks.length) reasons.push("unknown-or-malformed-blocks-preserved"); + if (unknownTokens.length) reasons.push("unknown-tokens-preserved"); + if (diagnostics.some((item) => item.code === "MISSING_VERSION")) reasons.push("missing-version-header"); + const projectSave = diagnostics.some((item) => item.severity === "error") ? "blocked" : "safe"; + const originalExport = projectSave === "safe" && unknownBlocks.length === 0 && unknownTokens.length === 0 ? "safe" : "blocked"; + return { + source, + header, + sections, + options, + fonts, + titles, + voices, + words, + attachments, + pages, + rawTokens, + unknownTokens, + unknownBlocks, + diagnostics, + compatibility: { projectSave, originalExport, reasons, requiresExternalReplay: true } + }; +} + +export function serializeText(result: ParseResult): string { + return result.source.text; +} + +export function serializeBytes(result: ParseResult): Uint8Array { + return encodeSource(serializeText(result), result.source.encoding, result.source.bom); +} + +export function hasExactRoundTrip(result: ParseResult): boolean { + return bytesEqual(result.source.bytes, serializeBytes(result)); +} + +export function classifyVoiceToken(raw: string): VoiceTokenKind { + if (raw === "$") return "terminator"; + if (/^(?:\|\||\|:|:\||::|\|)$/.test(raw)) return "barline"; + if (/^\d+\/\d+$/.test(raw)) return "time-signature"; + if (raw.startsWith("{")) return "brace"; + if (raw.startsWith('"')) return "text"; + if (/^(?:[b#]?[0-7Xx]|\[[^\]]+\])(?:[._-]*|\.{1,3})$/.test(raw)) return raw.startsWith("0") ? "rest" : "note"; + return "unknown"; +} diff --git a/src/domain/project-codec.ts b/src/domain/project-codec.ts new file mode 100644 index 0000000..468749d --- /dev/null +++ b/src/domain/project-codec.ts @@ -0,0 +1,237 @@ +import { + cloneDocument, + modelEquivalent, + refreshLyricSyllableEventIds, + type ProjectSourceSnapshot, + type ScoreDocument, + type StableId, + validateScore +} from "./score-model.js"; + +export const PROJECT_FORMAT = "jianpu-web" as const; +export const PROJECT_SCHEMA_VERSION = 1 as const; +export const PLAYBACK_POLICY_VERSION = 1 as const; + +type PersistedScore = Omit; + +export interface ProjectEnvelopeV1 { + format: typeof PROJECT_FORMAT; + schemaVersion: typeof PROJECT_SCHEMA_VERSION; + projectId: StableId; + source: ProjectSourceSnapshot | null; + score: PersistedScore; +} + +export interface ProjectCodecIssue { + code: string; + path: string; + message: string; +} + +export class ProjectCodecError extends Error { + readonly issues: ProjectCodecIssue[]; + + constructor(message: string, issues: ProjectCodecIssue[] = []) { + super(message); + this.name = "ProjectCodecError"; + this.issues = issues; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[], path: string, issues: ProjectCodecIssue[]): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(value)) { + if (!allowedSet.has(key)) issues.push({ code: "UNKNOWN_FIELD", path: `${path}.${key}`, message: "工程包含未支持字段,拒绝静默迁移。" }); + } +} + +function isSourceSnapshot(value: unknown): value is ProjectSourceSnapshot { + if (!isRecord(value)) return false; + return typeof value.fileName === "string" || value.fileName === null + ? (value.encoding === "utf-16le" || value.encoding === "utf-16be" || value.encoding === "utf-8" || value.encoding === "unknown") + && (value.bom === "utf-16le" || value.bom === "utf-16be" || value.bom === "utf-8" || value.bom === "none") + && (typeof value.sha256 === "string" || value.sha256 === null) + && (typeof value.rawBase64 === "string" || value.rawBase64 === null) + : false; +} + +function isPersistedScore(value: unknown): value is PersistedScore { + if (!isRecord(value)) return false; + return isRecord(value.title) + && Array.isArray(value.sections) + && Array.isArray(value.voices) + && Array.isArray(value.attachments) + && Array.isArray(value.pageObjects) + && Array.isArray(value.unknownBlocks) + && isRecord(value.documentOptions) + && isRecord(value.fontRoles) + && isRecord(value.manualLayout) + && value.playbackPolicyVersion === PLAYBACK_POLICY_VERSION; +} + +function requireKeys(value: Record, keys: readonly string[], path: string, issues: ProjectCodecIssue[]): void { + for (const key of keys) if (!(key in value)) issues.push({ code: "MISSING_FIELD", path: `${path}.${key}`, message: "工程缺少必需字段。" }); +} + +function checkObjectArray(value: unknown, path: string, allowed: readonly string[], required: readonly string[], issues: ProjectCodecIssue[]): void { + if (!Array.isArray(value)) { + issues.push({ code: "INVALID_TYPE", path, message: "字段必须为对象数组。" }); + return; + } + for (const [index, item] of value.entries()) { + const itemPath = `${path}[${index}]`; + if (!isRecord(item)) issues.push({ code: "INVALID_TYPE", path: itemPath, message: "数组项必须为对象。" }); + else { + hasOnlyKeys(item, allowed, itemPath, issues); + requireKeys(item, required, itemPath, issues); + } + } +} + +function checkRationalValue(value: unknown, path: string, issues: ProjectCodecIssue[]): void { + if (!isRecord(value) || !Number.isInteger(value.numerator) || !Number.isInteger(value.denominator) || value.denominator === 0) issues.push({ code: "INVALID_TYPE", path, message: "时值必须为非零分母的整数有理数。" }); +} + +function validateNestedShape(raw: Record): ProjectCodecIssue[] { + const issues: ProjectCodecIssue[] = []; + hasOnlyKeys(raw, ["format", "schemaVersion", "projectId", "source", "score"], "$", issues); + requireKeys(raw, ["format", "schemaVersion", "projectId", "source", "score"], "$", issues); + const source = raw.source; + if (source !== null) { + if (!isRecord(source)) issues.push({ code: "INVALID_TYPE", path: "$.source", message: "source 必须为对象或 null。" }); + else { + hasOnlyKeys(source, ["fileName", "encoding", "bom", "sha256", "rawBase64"], "$.source", issues); + requireKeys(source, ["fileName", "encoding", "bom", "sha256", "rawBase64"], "$.source", issues); + if (!isSourceSnapshot(source)) issues.push({ code: "INVALID_SOURCE", path: "$.source", message: "source 字段类型或编码值无效。" }); + } + } + if (!isRecord(raw.score)) { + issues.push({ code: "INVALID_TYPE", path: "$.score", message: "score 必须为对象。" }); + return issues; + } + const score = raw.score; + const scoreKeys = ["title", "sections", "voices", "attachments", "pageObjects", "unknownBlocks", "documentOptions", "fontRoles", "manualLayout", "playbackPolicyVersion"] as const; + hasOnlyKeys(score, scoreKeys, "$.score", issues); + requireKeys(score, scoreKeys, "$.score", issues); + if (!isRecord(score.title)) issues.push({ code: "INVALID_TYPE", path: "$.score.title", message: "title 必须为对象。" }); + else { + const titleKeys = ["title", "composer", "lyricist", "key", "meter", "tempo", "expression"] as const; + hasOnlyKeys(score.title, titleKeys, "$.score.title", issues); + requireKeys(score.title, titleKeys, "$.score.title", issues); + if (typeof score.title.title !== "string" || typeof score.title.composer !== "string" || typeof score.title.lyricist !== "string" || typeof score.title.key !== "string" || typeof score.title.meter !== "string" || typeof score.title.expression !== "string" || (typeof score.title.tempo !== "number" && score.title.tempo !== null)) issues.push({ code: "INVALID_TYPE", path: "$.score.title", message: "title 字段类型无效。" }); + } + if (!isRecord(score.manualLayout)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout", message: "manualLayout 必须为对象。" }); + else { + const layoutKeys = ["lockedMeasureIds", "forcedBreakAfterMeasureIds", "measureSpacing", "objectPositions", "measurePositions", "voiceOrder", "voiceSpacing", "voiceAlignment", "lyricOffsets", "fontOverrides"] as const; + hasOnlyKeys(score.manualLayout, layoutKeys, "$.score.manualLayout", issues); + requireKeys(score.manualLayout, ["lockedMeasureIds", "forcedBreakAfterMeasureIds", "measureSpacing", "objectPositions"], "$.score.manualLayout", issues); + if (!Array.isArray(score.manualLayout.lockedMeasureIds) || !Array.isArray(score.manualLayout.forcedBreakAfterMeasureIds) || !isRecord(score.manualLayout.measureSpacing) || !isRecord(score.manualLayout.objectPositions)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout", message: "manualLayout 字段类型无效。" }); + if (score.manualLayout.measurePositions !== undefined && !isRecord(score.manualLayout.measurePositions)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout.measurePositions", message: "measurePositions 必须为坐标对象。" }); + if (score.manualLayout.voiceOrder !== undefined && !Array.isArray(score.manualLayout.voiceOrder)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout.voiceOrder", message: "voiceOrder 必须为数组。" }); + if (score.manualLayout.voiceSpacing !== undefined && !isRecord(score.manualLayout.voiceSpacing)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout.voiceSpacing", message: "voiceSpacing 必须为对象。" }); + if (score.manualLayout.voiceAlignment !== undefined && !isRecord(score.manualLayout.voiceAlignment)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout.voiceAlignment", message: "voiceAlignment 必须为对象。" }); + if (score.manualLayout.lyricOffsets !== undefined && !isRecord(score.manualLayout.lyricOffsets)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout.lyricOffsets", message: "lyricOffsets 必须为对象。" }); + if (score.manualLayout.fontOverrides !== undefined && !isRecord(score.manualLayout.fontOverrides)) issues.push({ code: "INVALID_TYPE", path: "$.score.manualLayout.fontOverrides", message: "fontOverrides 必须为对象。" }); + } + checkObjectArray(score.sections, "$.score.sections", ["id", "name", "known", "sourceRange"], ["id", "name", "known", "sourceRange"], issues); + checkObjectArray(score.voices, "$.score.voices", ["id", "name", "sectionId", "visible", "measures", "temporary", "alignment", "fontRole"], ["id", "name", "sectionId", "visible", "measures"], issues); + checkObjectArray(score.attachments, "$.score.attachments", ["id", "kind", "raw", "anchor", "status", "sourceRange", "lyric", "visible", "privateGlyph"], ["id", "kind", "raw", "anchor", "status", "sourceRange"], issues); + checkObjectArray(score.pageObjects, "$.score.pageObjects", ["id", "kind", "raw", "sourceRange", "position", "visible"], ["id", "kind", "raw", "sourceRange"], issues); + checkObjectArray(score.unknownBlocks, "$.score.unknownBlocks", ["id", "reason", "rawText", "context", "sourceRange"], ["id", "reason", "rawText", "context", "sourceRange"], issues); + if (!isRecord(score.documentOptions) || !Object.values(score.documentOptions).every((value) => typeof value === "string")) issues.push({ code: "INVALID_TYPE", path: "$.score.documentOptions", message: "documentOptions 必须为字符串值对象。" }); + if (!isRecord(score.fontRoles) || !Object.values(score.fontRoles).every((value) => typeof value === "string")) issues.push({ code: "INVALID_TYPE", path: "$.score.fontRoles", message: "fontRoles 必须为字符串值对象。" }); + if (Array.isArray(score.voices)) { + for (const [voiceIndex, voice] of score.voices.entries()) { + if (!isRecord(voice)) continue; + checkObjectArray(voice.measures, `$.score.voices[${voiceIndex}].measures`, ["id", "ordinal", "beats", "events", "barlineBefore", "barlineAfter", "repeatStart", "repeatEnd", "ending", "tempoBpm", "meter", "key"], ["id", "ordinal", "beats", "events"], issues); + if (!Array.isArray(voice.measures)) continue; + for (const [measureIndex, measure] of voice.measures.entries()) { + if (!isRecord(measure)) continue; + checkRationalValue(measure.beats, `$.score.voices[${voiceIndex}].measures[${measureIndex}].beats`, issues); + checkObjectArray(measure.events, `$.score.voices[${voiceIndex}].measures[${measureIndex}].events`, ["id", "kind", "pitch", "duration", "text", "source", "decorations", "chord", "hidden", "lyricSkip", "tuplet", "privateGlyph", "velocity", "gateRatio"], ["id", "kind", "pitch", "duration", "text", "source"], issues); + if (!Array.isArray(measure.events)) continue; + for (const [eventIndex, event] of measure.events.entries()) { + if (!isRecord(event)) continue; + if (event.duration !== null) checkRationalValue(event.duration, `$.score.voices[${voiceIndex}].measures[${measureIndex}].events[${eventIndex}].duration`, issues); + if (!["note", "rest", "barline", "text", "unknown"].includes(String(event.kind))) issues.push({ code: "INVALID_TYPE", path: `$.score.voices[${voiceIndex}].measures[${measureIndex}].events[${eventIndex}].kind`, message: "事件 kind 无效。" }); + if (event.text !== null && typeof event.text !== "string") issues.push({ code: "INVALID_TYPE", path: `$.score.voices[${voiceIndex}].measures[${measureIndex}].events[${eventIndex}].text`, message: "事件 text 必须为字符串或 null。" }); + } + } + } + } + if (Array.isArray(score.attachments)) { + for (const [index, attachment] of score.attachments.entries()) { + if (!isRecord(attachment) || !isRecord(attachment.anchor)) continue; + hasOnlyKeys(attachment.anchor, ["raw", "startId", "endId"], `$.score.attachments[${index}].anchor`, issues); + requireKeys(attachment.anchor, ["raw", "startId", "endId"], `$.score.attachments[${index}].anchor`, issues); + } + } + if (score.playbackPolicyVersion !== PLAYBACK_POLICY_VERSION) issues.push({ code: "UNSUPPORTED_PLAYBACK_POLICY", path: "$.score.playbackPolicyVersion", message: "播放规则版本不受支持。" }); + return issues; +} + +function validateEnvelope(raw: unknown): raw is ProjectEnvelopeV1 { + if (!isRecord(raw)) throw new ProjectCodecError("工程 JSON 根节点必须为对象。", [{ code: "INVALID_ROOT", path: "$", message: "工程 JSON 根节点必须为对象。" }]); + const issues = validateNestedShape(raw); + if (raw.format !== PROJECT_FORMAT) issues.push({ code: "UNSUPPORTED_FORMAT", path: "$.format", message: "工程格式标识不受支持。" }); + if (raw.schemaVersion !== PROJECT_SCHEMA_VERSION) issues.push({ code: "UNSUPPORTED_SCHEMA", path: "$.schemaVersion", message: "工程 schemaVersion 不受支持。" }); + if (typeof raw.projectId !== "string" || !raw.projectId) issues.push({ code: "INVALID_PROJECT_ID", path: "$.projectId", message: "projectId 必须为非空字符串。" }); + if (raw.source !== null && !isSourceSnapshot(raw.source)) issues.push({ code: "INVALID_SOURCE", path: "$.source", message: "source 字段无效。" }); + if (!isPersistedScore(raw.score)) issues.push({ code: "INVALID_SCORE", path: "$.score", message: "score 字段结构无效。" }); + if (issues.length) throw new ProjectCodecError("工程结构校验失败。", issues); + return true; +} + +export function projectFromModel(model: ScoreDocument): ProjectEnvelopeV1 { + const validation = validateScore(model); + if (!validation.valid) throw new ProjectCodecError("模型存在阻止保存的错误。", validation.diagnostics.map((item) => ({ code: item.code, path: item.objectId ?? "$.score", message: item.message }))); + const cloned = cloneDocument(model); + const { documentId, source, ...score } = cloned; + return { format: PROJECT_FORMAT, schemaVersion: PROJECT_SCHEMA_VERSION, projectId: documentId, source, score }; +} + +export function encodeProject(model: ScoreDocument): string { + return `${JSON.stringify(projectFromModel(model), null, 2)}\n`; +} + +export function modelFromProject(project: ProjectEnvelopeV1): ScoreDocument { + return refreshLyricSyllableEventIds({ + ...cloneDocument({ ...project.score, documentId: project.projectId, source: project.source }), + documentId: project.projectId, + source: project.source + }); +} + +export function decodeProject(text: string): ScoreDocument { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + throw new ProjectCodecError("工程 JSON 无法解析。", [{ code: "INVALID_JSON", path: "$", message: error instanceof Error ? error.message : "JSON parse failed" }]); + } + if (!validateEnvelope(raw)) throw new ProjectCodecError("工程结构无效。"); + const project: ProjectEnvelopeV1 = { + format: PROJECT_FORMAT, + schemaVersion: PROJECT_SCHEMA_VERSION, + projectId: raw.projectId, + source: raw.source, + score: raw.score + }; + const model = modelFromProject(project); + const validation = validateScore(model); + if (!validation.valid) throw new ProjectCodecError("工程模型引用或时值校验失败。", validation.diagnostics.map((item) => ({ code: item.code, path: item.objectId ?? "$.score", message: item.message }))); + return model; +} + +export function roundTripProject(model: ScoreDocument): { text: string; reopened: ScoreDocument } { + const text = encodeProject(model); + return { text, reopened: decodeProject(text) }; +} + +export function projectEquivalentAfterReopen(left: ScoreDocument, right: ScoreDocument): boolean { + return modelEquivalent(left, right); +} diff --git a/src/domain/project-schema.ts b/src/domain/project-schema.ts new file mode 100644 index 0000000..2497b8c --- /dev/null +++ b/src/domain/project-schema.ts @@ -0,0 +1,17 @@ +export { PLAYBACK_POLICY_VERSION, PROJECT_FORMAT, PROJECT_SCHEMA_VERSION } from "./project-codec.js"; +export type { ProjectEnvelopeV1 as ProjectEnvelope, ProjectCodecIssue } from "./project-codec.js"; +export type { + AnchorRef, + AnchoredAttachment, + ManualLayoutState, + PageObjectModel, + ProjectSourceSnapshot, + ScoreDocument, + ScoreEvent, + ScoreMeasure, + ScoreSection, + StableId, + TitleBlock, + UnknownBlockModel, + VoiceGroup +} from "./score-model.js"; diff --git a/src/domain/score-model.ts b/src/domain/score-model.ts new file mode 100644 index 0000000..64a610f --- /dev/null +++ b/src/domain/score-model.ts @@ -0,0 +1,743 @@ +import type { ParseResult, SourceRange } from "./jpwabc.js"; + +export type StableId = string; + +export interface Rational { + numerator: number; + denominator: number; +} + +export type EventKind = "note" | "rest" | "barline" | "text" | "unknown"; +export type BarlineStyle = "|" | "||" | "|:" | ":|" | "::" | "dashed" | "unknown"; +export type DecorationPlayback = "w1" | "visual" | "unknown"; + +export interface DecorationMark { + id: StableId; + kind: string; + raw: string; + playback: DecorationPlayback; + sourceRange: SourceRange | null; +} + +export interface TupletRef { + id: StableId; + ratio: number; + ordinal: number; + raw: string; +} + +export interface PrivateGlyphRef { + fontRole: string | null; + codePoint: number | null; + raw: string; +} + +export interface Pitch { + degree: number | null; + accidental: -1 | 0 | 1; + octave: number | null; +} + +export interface SourceBinding { + range: SourceRange | null; + raw: string | null; +} + +export interface ScoreEvent { + id: StableId; + kind: EventKind; + pitch: Pitch | null; + duration: Rational | null; + text: string | null; + source: SourceBinding | null; + decorations?: DecorationMark[]; + chord?: Pitch[]; + hidden?: boolean; + lyricSkip?: boolean; + tuplet?: TupletRef | null; + privateGlyph?: PrivateGlyphRef | null; + velocity?: number; + gateRatio?: Rational | null; +} + +export interface ScoreMeasure { + id: StableId; + ordinal: number; + beats: Rational; + events: ScoreEvent[]; + barlineBefore?: BarlineStyle; + barlineAfter?: BarlineStyle; + repeatStart?: boolean; + repeatEnd?: boolean; + ending?: number | null; + tempoBpm?: number | null; + meter?: { numerator: number; denominator: number } | null; + key?: string | null; +} + +export type VoiceAlignment = "aligned" | "independent" | "overlay"; + +export interface VoiceGroup { + id: StableId; + name: string; + sectionId: StableId | null; + visible: boolean; + measures: ScoreMeasure[]; + temporary?: boolean; + alignment?: VoiceAlignment; + fontRole?: string | null; +} + +export interface ScoreSection { + id: StableId; + name: string; + known: boolean; + sourceRange: SourceRange | null; +} + +export interface AnchorRef { + raw: string | null; + startId: StableId | null; + endId: StableId | null; +} + +export type AttachmentStatus = "attached" | "orphaned" | "unresolved"; + +export interface LyricMetadata { + verseStart: number | null; + verseEnd: number | null; + prefix: string | null; + lines: string[]; + syllables: string[]; + syllableEventIds?: Array; +} + +export interface AnchoredAttachment { + id: StableId; + kind: string; + raw: string; + anchor: AnchorRef; + status: AttachmentStatus; + sourceRange: SourceRange | null; + lyric?: LyricMetadata; + visible?: boolean; + privateGlyph?: PrivateGlyphRef | null; +} + +export interface PageObjectModel { + id: StableId; + kind: string; + raw: string; + sourceRange: SourceRange | null; + position?: { x: number; y: number } | null; + visible?: boolean; +} + +export interface UnknownBlockModel { + id: StableId; + reason: string; + rawText: string; + context: string; + sourceRange: SourceRange | null; +} + +export interface ManualFontOverride { + role?: string; + sizePx?: number; + scaleX?: number; + scaleY?: number; +} + +export interface ManualLayoutState { + lockedMeasureIds: StableId[]; + forcedBreakAfterMeasureIds: StableId[]; + measureSpacing: Record; + objectPositions: Record; + measurePositions?: Record; + voiceOrder?: StableId[]; + voiceSpacing?: Record; + voiceAlignment?: Record; + lyricOffsets?: Record; + fontOverrides?: Record; +} + +export interface TitleBlock { + title: string; + composer: string; + lyricist: string; + key: string; + meter: string; + tempo: number | null; + expression: string; +} + +export interface ProjectSourceSnapshot { + fileName: string | null; + encoding: "utf-16le" | "utf-16be" | "utf-8" | "unknown"; + bom: "utf-16le" | "utf-16be" | "utf-8" | "none"; + sha256: string | null; + rawBase64: string | null; +} + +export interface ScoreDocument { + documentId: StableId; + title: TitleBlock; + sections: ScoreSection[]; + voices: VoiceGroup[]; + attachments: AnchoredAttachment[]; + pageObjects: PageObjectModel[]; + unknownBlocks: UnknownBlockModel[]; + documentOptions: Record; + fontRoles: Record; + manualLayout: ManualLayoutState; + playbackPolicyVersion: 1; + source: ProjectSourceSnapshot | null; +} + +export interface ModelDiagnostic { + code: string; + severity: "error" | "warning"; + message: string; + objectId: StableId | null; +} + +export interface ModelValidation { + valid: boolean; + diagnostics: ModelDiagnostic[]; +} + +const textEncoder = new TextEncoder(); + +function gcd(left: number, right: number): number { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) [a, b] = [b, a % b]; + return a || 1; +} + +export function rational(numerator: number, denominator = 1): Rational { + if (!Number.isInteger(numerator) || !Number.isInteger(denominator) || denominator === 0) throw new Error("invalid rational"); + const sign = denominator < 0 ? -1 : 1; + const divisor = gcd(numerator, denominator); + return { numerator: sign * numerator / divisor, denominator: sign * denominator / divisor }; +} + +export function addRational(left: Rational, right: Rational): Rational { + return rational(left.numerator * right.denominator + right.numerator * left.denominator, left.denominator * right.denominator); +} + +export function compareRational(left: Rational, right: Rational): number { + const delta = left.numerator * right.denominator - right.numerator * left.denominator; + return delta === 0 ? 0 : delta < 0 ? -1 : 1; +} + +export function rationalToString(value: Rational): string { + return `${value.numerator}/${value.denominator}`; +} + +function fnv1a(input: string): string { + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +export function stableId(kind: string, seed: string): StableId { + return `${kind}-${fnv1a(`${kind}\u0000${seed}`)}`; +} + +export function freshId(kind: string): StableId { + const uuid = globalThis.crypto?.randomUUID?.(); + return `${kind}-${uuid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`; +} + +function clone(value: T): T { + return structuredClone(value); +} + +export function cloneDocument(document: ScoreDocument): ScoreDocument { + return clone(document); +} + +function base64FromBytes(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + 0x8000, bytes.length))); + } + return globalThis.btoa(binary); +} + +function durationFromRaw(raw: string): Rational { + const markCount = (raw.match(/_+/)?.[0].length ?? 0); + const base = rational(1, 2 ** markCount); + const increaseCount = raw.match(/-+/)?.[0].length ?? 0; + if (increaseCount > 0) return rational(base.numerator * (increaseCount + 1), base.denominator); + return raw.includes(".") ? rational(base.numerator * 3, base.denominator * 2) : base; +} + +function octaveFromRaw(raw: string): number { + const down = (raw.match(/d/g) ?? []).length + (raw.match(/,/g) ?? []).length; + const up = (raw.match(/g/g) ?? []).length + (raw.match(/'/g) ?? []).length; + return up - down; +} + +function pitchFromRaw(raw: string): Pitch | null { + const match = raw.match(/^[b#]?(\d)/); + if (!match) return null; + const degree = Number(match[1]); + if (degree === 0) return null; + return { degree, accidental: raw.startsWith("#") ? 1 : raw.startsWith("b") ? -1 : 0, octave: octaveFromRaw(raw) }; +} + +function chordFromRaw(raw: string): Pitch[] | undefined { + if (!raw.startsWith("[") || !raw.endsWith("]")) return undefined; + const tones = [...raw.slice(1, -1).matchAll(/[b#]?[1-7][d,g']*/g)].map((match) => pitchFromRaw(match[0])).filter((pitch): pitch is Pitch => pitch !== null); + return tones.length ? tones : undefined; +} + +function privateGlyphFromRaw(raw: string): PrivateGlyphRef | null { + const escaped = raw.match(/\\x([Ff][0-9A-Fa-f]{3,4})/); + if (escaped) return { fontRole: null, codePoint: Number.parseInt(escaped[1], 16), raw: escaped[0] }; + const privateUse = [...raw].find((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return (codePoint >= 0xe000 && codePoint <= 0xf8ff) || (codePoint >= 0xf0000 && codePoint <= 0xffffd); + }); + return privateUse ? { fontRole: null, codePoint: privateUse.codePointAt(0) ?? null, raw: privateUse } : null; +} + +function decorationFromRaw(raw: string, ordinal: string, sourceRange: SourceRange | null): DecorationMark { + const body = raw.replace(/^\{/, "").replace(/\}$/, ""); + const head = body.split(/[,:=\s]/, 1)[0] || "unknown"; + const playback: DecorationPlayback = /^(?:DunYin|BoYin|YanYin|ZhongYin|HuanQi|Force-p|p|pp|mp|mf|f|ff)$/i.test(head) ? "w1" : "visual"; + return { id: stableId("decoration", ordinal), kind: head, raw, playback, sourceRange }; +} + +function tupletFromRaw(raw: string, ordinal: string): TupletRef | null { + const match = raw.match(/\{\s*\(?\s*(\d+)\s*\}/); + if (!match) return null; + const ratio = Number(match[1]); + return ratio > 1 ? { id: stableId("tuplet", ordinal), ratio, ordinal: 0, raw } : null; +} + +function normalizeBarline(raw: string): BarlineStyle { + if (raw === "|" || raw === "||" || raw === "|:" || raw === ":|" || raw === "::") return raw; + return raw.includes("-") ? "dashed" : "unknown"; +} + +function eventFromToken(raw: string, kind: EventKind, source: SourceBinding | null, ordinal: string, decorations: DecorationMark[] = [], tuplet: TupletRef | null = null): ScoreEvent { + const chord = chordFromRaw(raw); + const pitch = kind === "note" ? (pitchFromRaw(raw) ?? chord?.[0] ?? null) : null; + const privateGlyph = privateGlyphFromRaw(raw); + return { + id: stableId("event", ordinal), + kind, + pitch, + duration: kind === "note" || kind === "rest" ? durationFromRaw(raw) : null, + text: kind === "text" || kind === "unknown" ? raw : null, + source, + ...(decorations.length ? { decorations } : {}), + ...(chord ? { chord } : {}), + ...(tuplet ? { tuplet } : {}), + ...(privateGlyph ? { privateGlyph } : {}), + ...( /(?:Hide|hidden|OnlyDuration)/i.test(raw) ? { hidden: true } : {}) + }; +} + +type MeasureTokenChunk = { tokens: ParseResult["voices"][number]["tokens"]; before?: BarlineStyle; after?: BarlineStyle }; + +function measuresFromTokens(tokens: ParseResult["voices"][number]["tokens"], voiceIndex: number): ScoreMeasure[] { + const chunks: MeasureTokenChunk[] = []; + let current: ParseResult["voices"][number]["tokens"] = []; + let before: BarlineStyle | undefined; + let lastBarline: BarlineStyle | undefined; + for (const token of tokens) { + if (token.kind === "terminator") continue; + if (token.kind === "barline") { + const style = normalizeBarline(token.raw); + if (current.length) { + chunks.push({ tokens: current, before, after: style }); + current = []; + before = undefined; + } else { + before = style; + if (chunks.length) chunks[chunks.length - 1].after = style; + } + lastBarline = style; + } else { + current.push(token); + lastBarline = undefined; + } + } + if (current.length) chunks.push({ tokens: current, before, after: lastBarline }); + else if (lastBarline === ":|" && chunks.length) chunks.push({ tokens: [], before: lastBarline, after: lastBarline }); + if (!chunks.length) return []; + return chunks.map((chunk, measureIndex) => { + const events: ScoreEvent[] = []; + let total = rational(0); + let pendingDecorations: DecorationMark[] = []; + let pendingTuplet: TupletRef | null = null; + let tupletOrdinal = 0; + for (const [tokenIndex, token] of chunk.tokens.entries()) { + if (token.kind === "brace") { + const tuplet = tupletFromRaw(token.raw, `${voiceIndex}:${measureIndex}:${tokenIndex}`); + if (tuplet) { pendingTuplet = tuplet; tupletOrdinal = 0; } + else if (events.length) events[events.length - 1].decorations = [...(events[events.length - 1].decorations ?? []), decorationFromRaw(token.raw, `${voiceIndex}:${measureIndex}:${tokenIndex}`, token.range)]; + else pendingDecorations.push(decorationFromRaw(token.raw, `${voiceIndex}:${measureIndex}:${tokenIndex}`, token.range)); + continue; + } + const kind: EventKind = token.kind === "note" ? "note" : token.kind === "rest" ? "rest" : token.kind === "text" ? "text" : "unknown"; + const tuplet = pendingTuplet ? { ...pendingTuplet, ordinal: tupletOrdinal++ } : null; + const event = eventFromToken(token.raw, kind, { range: token.range, raw: token.raw }, `${voiceIndex}:${measureIndex}:${tokenIndex}:${token.range.start}`, pendingDecorations, tuplet); + pendingDecorations = []; + events.push(event); + if (pendingTuplet && tupletOrdinal >= pendingTuplet.ratio) pendingTuplet = null; + if (event.duration) total = addRational(total, event.duration); + } + return { + id: stableId("measure", `${voiceIndex}:${measureIndex}`), + ordinal: measureIndex, + beats: total.numerator > 0 ? total : rational(1), + events, + ...(chunk.before ? { barlineBefore: chunk.before } : {}), + ...(chunk.after ? { barlineAfter: chunk.after } : {}), + ...(chunk.before === "|:" || chunk.after === "|:" ? { repeatStart: true } : {}), + ...(chunk.after === ":|" || chunk.before === ":|" ? { repeatEnd: true } : {}) + }; + }); +} + +function orderedEvents(voices: VoiceGroup[]): ScoreEvent[] { + return voices.flatMap((voice) => voice.measures.flatMap((measure) => measure.events)); +} + +export function resolveLyricSyllableEventIds(anchor: AnchorRef, syllables: string[], voices: VoiceGroup[]): Array { + const unresolved = syllables.map(() => null as StableId | null); + if (!anchor.startId) return unresolved; + const voice = voices.find((candidate) => candidate.measures.some((measure) => measure.events.some((event) => event.id === anchor.startId))); + const events = voice ? voice.measures.flatMap((measure) => measure.events) : orderedEvents(voices); + let index = events.findIndex((event) => event.id === anchor.startId); + if (index < 0) return unresolved; + return syllables.map(() => { + while (index < events.length && (events[index].kind === "rest" || events[index].kind === "barline" || events[index].lyricSkip)) index += 1; + const event = events[index]; + index += 1; + return event?.id ?? null; + }); +} + +function resolveAnchor(raw: string, voices: VoiceGroup[], lyric: boolean): AnchorRef { + const pair = raw.match(/@?(\d+)\s*,\s*(\d+)/); + const first = pair ? Number(pair[1]) : 0; + const second = pair ? Number(pair[2]) : 0; + const voiceIndex = lyric ? first - 1 : second - 1; + const eventOrdinal = lyric ? second : first; + const voiceEvents = voices[voiceIndex]?.measures.flatMap((measure) => measure.events) ?? []; + const fallbackEvents = orderedEvents(voices); + const target = eventOrdinal > 0 ? (voiceEvents[eventOrdinal - 1] ?? fallbackEvents[eventOrdinal - 1]) : undefined; + return { raw, startId: target?.id ?? null, endId: null }; +} + +function lyricMetadata(header: string, lines: string[]): LyricMetadata { + const match = header.match(/^W(\d+)(?:-(\d+))?/i); + const prefix = lines[0]?.match(/^\s*(\{[^}]*\})/)?.[1] ?? null; + const body = lines.map((line) => line.replace(/\{[^}]*\}/g, "")).join("\n"); + const syllables = [...body.matchAll(/[A-Za-z]+(?:['’_-][A-Za-z]+)*|[\u4e00-\u9fff]|[0-9]+/g)].map((item) => item[0]); + return { + verseStart: match ? Number(match[1]) : null, + verseEnd: match?.[2] ? Number(match[2]) : null, + prefix, + lines: [...lines], + syllables + }; +} + +export function lyricMetadataFromRaw(raw: string): LyricMetadata | undefined { + const lines = raw.split(/\r?\n/); + const header = lines.shift()?.replace(/:\s*$/, "") ?? ""; + return /^W/i.test(header) ? lyricMetadata(header, lines) : undefined; +} + +function pagePositionFromAnchor(raw: string): { x: number; y: number } | null { + const values = [...raw.matchAll(/-?\d+(?:\.\d+)?/g)].map((match) => Number(match[0])); + if (values.length < 2) return null; + return { x: values.at(-2) ?? 0, y: values.at(-1) ?? 0 }; +} + +function attachmentAnchorNeedsDiagnosis(attachment: AnchoredAttachment): boolean { + return attachment.kind === "Text" && /\([^)]*\)/.test(attachment.anchor.raw ?? ""); +} + +function titleFromParse(result: ParseResult): TitleBlock { + const fields = new Map(result.titles.map((entry) => [entry.field.toLowerCase(), entry.value])); + const keyAndMeters = fields.get("keyandmeters") ?? ""; + const keyMatch = keyAndMeters.match(/1\s*=\s*([A-Ga-g][#b]?)/); + const meterMatch = keyAndMeters.match(/(\d+\s*\/\s*\d+)/); + const expression = fields.get("expression") ?? ""; + const expressionTempo = expression.match(/\{[^}]*J\s*=\s*(\d+(?:\.\d+)?)/i)?.[1] ?? ""; + const tempoValue = fields.get("tempo") ?? fields.get("bpm") ?? expressionTempo; + const tempo = tempoValue && Number.isFinite(Number(tempoValue)) ? Number(tempoValue) : null; + return { + title: fields.get("title") ?? "", + composer: fields.get("composer") ?? fields.get("music") ?? fields.get("author") ?? fields.get("wordsbymusicby") ?? "", + lyricist: fields.get("lyricist") ?? fields.get("words") ?? "", + key: fields.get("key") ?? fields.get("tone") ?? (keyMatch?.[1] ?? ""), + meter: fields.get("meter") ?? fields.get("beat") ?? (meterMatch?.[1].replaceAll(" ", "") ?? ""), + tempo, + expression + }; +} + +export function modelFromParse(result: ParseResult, options: { fileName?: string | null; sourceSha256?: string | null } = {}): ScoreDocument { + const documentId = stableId("document", result.source.text); + const sections = result.sections.map((section) => ({ + id: stableId("section", `${documentId}:${section.name}:${section.range.start}`), + name: section.name, + known: section.known, + sourceRange: section.range + })); + const voiceLines = result.voices; + const voices: VoiceGroup[] = voiceLines.map((line, voiceIndex) => ({ + id: stableId("voice", `${documentId}:${voiceIndex}:${line.range.start}`), + name: `Voice ${voiceIndex + 1}`, + sectionId: sections.find((section) => section.name === ".Voice")?.id ?? null, + visible: true, + measures: measuresFromTokens(line.tokens, voiceIndex) + })); + const parsedAttachments = result.attachments.map((attachment, index) => { + const anchor = resolveAnchor(attachment.anchor, voices, false); + return { + id: stableId("attachment", `${documentId}:${index}:${attachment.range.start}`), + kind: attachment.kind, + raw: attachment.raw, + anchor, + status: anchor.startId && !attachmentAnchorNeedsDiagnosis({ id: "diagnostic", kind: attachment.kind, raw: attachment.raw, anchor, status: "unresolved", sourceRange: attachment.range }) ? "attached" as const : "unresolved" as const, + sourceRange: attachment.range, + visible: true, + privateGlyph: privateGlyphFromRaw(attachment.raw) + }; + }); + const lyricAttachments = result.words.map((word, index) => { + const anchor = resolveAnchor(word.header, voices, true); + const lyric = lyricMetadata(word.header, word.rawLines); + return { + id: stableId("lyric", `${documentId}:${index}:${word.range.start}`), + kind: "Lyric", + raw: `${word.header}:\n${word.rawLines.join("\n")}`, + anchor, + status: anchor.startId ? "attached" as const : "unresolved" as const, + sourceRange: word.range, + lyric: { ...lyric, syllableEventIds: resolveLyricSyllableEventIds(anchor, lyric.syllables, voices) }, + visible: true + }; + }); + const attachments = [...parsedAttachments, ...lyricAttachments]; + const pages = result.pages.map((page, index) => ({ + id: stableId("page-object", `${documentId}:${index}:${page.range.start}`), + kind: page.kind, + raw: page.raw, + sourceRange: page.range, + position: pagePositionFromAnchor(page.anchor), + visible: true + })); + const unknownBlocks = result.unknownBlocks.map((block) => ({ + id: stableId("unknown", `${documentId}:${block.id}:${block.range.start}`), + reason: block.reason, + rawText: block.rawText, + context: block.context, + sourceRange: block.range + })); + const documentOptions = Object.fromEntries(result.options.document.map((entry) => [entry.key, entry.value])); + const fontRoles = Object.fromEntries(result.fonts.map((entry) => [entry.role, entry.family ?? ""])); + return { + documentId, + title: titleFromParse(result), + sections, + voices, + attachments, + pageObjects: pages, + unknownBlocks, + documentOptions, + fontRoles, + manualLayout: normalizeManualLayout({ lockedMeasureIds: [], forcedBreakAfterMeasureIds: [], measureSpacing: {}, objectPositions: {} }), + playbackPolicyVersion: 1, + source: { + fileName: options.fileName ?? null, + encoding: result.source.encoding, + bom: result.source.bom, + sha256: options.sourceSha256 ?? null, + rawBase64: base64FromBytes(result.source.bytes) + } + }; +} + +export function normalizeManualLayout(layout: ManualLayoutState | null | undefined): ManualLayoutState { + return { + lockedMeasureIds: [...(layout?.lockedMeasureIds ?? [])], + forcedBreakAfterMeasureIds: [...(layout?.forcedBreakAfterMeasureIds ?? [])], + measureSpacing: { ...(layout?.measureSpacing ?? {}) }, + objectPositions: { ...(layout?.objectPositions ?? {}) }, + measurePositions: { ...(layout?.measurePositions ?? {}) }, + voiceOrder: [...(layout?.voiceOrder ?? [])], + voiceSpacing: { ...(layout?.voiceSpacing ?? {}) }, + voiceAlignment: { ...(layout?.voiceAlignment ?? {}) }, + lyricOffsets: { ...(layout?.lyricOffsets ?? {}) }, + fontOverrides: { ...(layout?.fontOverrides ?? {}) } + }; +} + +function collectObjects(document: ScoreDocument): Map { + const objects = new Map(); + const add = (id: StableId, kind: string) => objects.set(id, kind); + add(document.documentId, "document"); + for (const section of document.sections) add(section.id, "section"); + for (const voice of document.voices) { + add(voice.id, "voice"); + for (const measure of voice.measures) { + add(measure.id, "measure"); + for (const event of measure.events) add(event.id, "event"); + } + } + for (const attachment of document.attachments) add(attachment.id, "attachment"); + for (const pageObject of document.pageObjects) add(pageObject.id, "page-object"); + for (const block of document.unknownBlocks) add(block.id, "unknown-block"); + return objects; +} + +export function validateScore(document: ScoreDocument): ModelValidation { + const diagnostics: ModelDiagnostic[] = []; + const objects = new Map(); + const addObject = (id: StableId, kind: string) => { + const previous = objects.get(id); + if (previous) diagnostics.push({ code: "DUPLICATE_ID", severity: "error", message: `稳定 ID ${id} 同时用于 ${previous} 和 ${kind}。`, objectId: id }); + else objects.set(id, kind); + }; + addObject(document.documentId, "document"); + for (const section of document.sections) addObject(section.id, "section"); + const alignedMeasureCounts = document.voices.filter((voice) => voice.visible !== false && voice.alignment !== "independent").map((voice) => voice.measures.length); + if (alignedMeasureCounts.length > 1 && new Set(alignedMeasureCounts).size > 1) diagnostics.push({ code: "VOICE_MEASURE_COUNT_MISMATCH", severity: "warning", message: "对齐声部的小节数不一致,跨行/空行需要人工确认。", objectId: document.documentId }); + for (const voice of document.voices) { + addObject(voice.id, "voice"); + for (const measure of voice.measures) { + addObject(measure.id, "measure"); + if (compareRational(measure.beats, rational(0)) <= 0) diagnostics.push({ code: "INVALID_MEASURE_BEATS", severity: "error", message: "小节时值上限必须为正数。", objectId: measure.id }); + let total = rational(0); + let timedEventCount = 0; + for (const event of measure.events) { + addObject(event.id, "event"); + if (event.duration) { + timedEventCount += 1; + if (compareRational(event.duration, rational(0)) <= 0) diagnostics.push({ code: "INVALID_EVENT_DURATION", severity: "error", message: "音符/休止时值必须为正数。", objectId: event.id }); + total = addRational(total, event.duration); + } + } + if (timedEventCount > 0 && compareRational(total, measure.beats) !== 0) diagnostics.push({ code: "MEASURE_DURATION_MISMATCH", severity: "error", message: `小节实际时值 ${rationalToString(total)} 与声明值 ${rationalToString(measure.beats)} 不一致。`, objectId: measure.id }); + if (timedEventCount === 0) diagnostics.push({ code: "EMPTY_MEASURE", severity: "warning", message: "小节没有带时值的音符/休止,按空小节诊断。", objectId: measure.id }); + } + } + for (const attachment of document.attachments) { + addObject(attachment.id, "attachment"); + const startKnown = !attachment.anchor.startId || objects.has(attachment.anchor.startId); + const endKnown = !attachment.anchor.endId || objects.has(attachment.anchor.endId); + if (!startKnown || !endKnown || (!attachment.anchor.startId && !attachment.anchor.endId && attachment.anchor.raw)) { + diagnostics.push({ code: "ORPHANED_ANCHOR", severity: "warning", message: `附件 ${attachment.id} 的锚点无法解析,已保留为孤立/未决对象。`, objectId: attachment.id }); + } + } + for (const pageObject of document.pageObjects) addObject(pageObject.id, "page-object"); + for (const block of document.unknownBlocks) addObject(block.id, "unknown-block"); + return { valid: diagnostics.every((item) => item.severity !== "error"), diagnostics }; +} + +export function refreshLyricSyllableEventIds(document: ScoreDocument): ScoreDocument { + const next = cloneDocument(document); + for (const attachment of next.attachments) { + if (attachment.kind === "Lyric" && attachment.lyric) attachment.lyric.syllableEventIds = resolveLyricSyllableEventIds(attachment.anchor, attachment.lyric.syllables, next.voices); + } + return next; +} + +export function normalizeAttachmentStatuses(document: ScoreDocument): ScoreDocument { + const next = cloneDocument(document); + next.manualLayout = normalizeManualLayout(next.manualLayout); + const objects = collectObjects(next); + for (const attachment of next.attachments) { + const startKnown = !attachment.anchor.startId || objects.has(attachment.anchor.startId); + const endKnown = !attachment.anchor.endId || objects.has(attachment.anchor.endId); + if (attachment.status === "unresolved" && attachmentAnchorNeedsDiagnosis(attachment)) continue; + attachment.status = startKnown && endKnown && (attachment.anchor.startId !== null || attachment.anchor.endId !== null) ? "attached" : attachment.anchor.raw ? "orphaned" : "unresolved"; + if (attachment.kind === "Lyric" && attachment.lyric) attachment.lyric.syllableEventIds = resolveLyricSyllableEventIds(attachment.anchor, attachment.lyric.syllables, next.voices); + } + return next; +} + +export class ScoreModel { + private document: ScoreDocument; + + constructor(document: ScoreDocument) { + this.document = normalizeAttachmentStatuses(document); + const validation = validateScore(this.document); + if (!validation.valid) throw new Error(validation.diagnostics.map((item) => item.message).join("\n")); + } + + snapshot(): ScoreDocument { + return cloneDocument(this.document); + } + + replace(document: ScoreDocument): void { + const next = normalizeAttachmentStatuses(document); + const validation = validateScore(next); + if (!validation.valid) throw new Error(validation.diagnostics.map((item) => item.message).join("\n")); + this.document = next; + } + + validate(): ModelValidation { + return validateScore(this.document); + } +} + +export function createEmptyDocument(documentId = freshId("document")): ScoreDocument { + return { + documentId, + title: { title: "", composer: "", lyricist: "", key: "", meter: "", tempo: null, expression: "" }, + sections: [], + voices: [], + attachments: [], + pageObjects: [], + unknownBlocks: [], + documentOptions: {}, + fontRoles: {}, + manualLayout: normalizeManualLayout({ lockedMeasureIds: [], forcedBreakAfterMeasureIds: [], measureSpacing: {}, objectPositions: {} }), + playbackPolicyVersion: 1, + source: null + }; +} + +export function createNoteEvent(id = freshId("event"), degree = 1, duration = rational(1)): ScoreEvent { + return { id, kind: "note", pitch: { degree, accidental: 0, octave: 0 }, duration, text: null, source: null }; +} + +export function createRestEvent(id = freshId("event"), duration = rational(1)): ScoreEvent { + return { id, kind: "rest", pitch: null, duration, text: null, source: null }; +} + +type CanonicalJson = null | string | number | boolean | CanonicalJson[] | { [key: string]: CanonicalJson }; + +function canonicalValue(value: unknown): CanonicalJson { + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.map((item) => canonicalValue(item)); + if (typeof value === "object") { + const result: { [key: string]: CanonicalJson } = {}; + for (const [key, item] of Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) result[key] = canonicalValue(item); + return result; + } + return String(value); +} + +export function modelEquivalent(left: ScoreDocument, right: ScoreDocument): boolean { + return JSON.stringify(canonicalValue(left)) === JSON.stringify(canonicalValue(right)); +} + +export function sourceBytesFromText(text: string): Uint8Array { + return textEncoder.encode(text); +} diff --git a/src/layout/font-measure.ts b/src/layout/font-measure.ts new file mode 100644 index 0000000..61bb97c --- /dev/null +++ b/src/layout/font-measure.ts @@ -0,0 +1,82 @@ +import type { FontMeasurement, FontRole, PaperConfig } from "./types.js"; + +export interface FontMeasurementProvider { + ready(): Promise; + measure(text: string, role: FontRole, config: PaperConfig): FontMeasurement; +} + +function roleStyle(role: FontRole, config: PaperConfig) { + return config.fontRoles[role] ?? config.fontRoles.text; +} + +export class DeterministicFontMeasurementProvider implements FontMeasurementProvider { + private readyState = false; + + async ready(): Promise { + this.readyState = true; + } + + measure(text: string, role: FontRole, config: PaperConfig): FontMeasurement { + const style = roleStyle(role, config); + const widthFactor = role === "note" ? 0.62 : role === "title" ? 0.58 : 0.52; + const wideCharacters = [...text].filter((char) => /[^\x00-\xff]/.test(char)).length; + const asciiCharacters = [...text].length - wideCharacters; + const widthPx = wideCharacters * style.sizePx + asciiCharacters * style.sizePx * widthFactor; + const heightMm = style.sizePx * 25.4 / 96 * 1.15; + return { + text, + role, + widthMm: Math.max(0.5, widthPx * 25.4 / 96), + heightMm, + baselineMm: heightMm * 0.82, + family: style.family, + loaded: this.readyState, + fallback: false + }; + } +} + +export class BrowserFontMeasurementProvider implements FontMeasurementProvider { + private canvas: HTMLCanvasElement | null = null; + private context: CanvasRenderingContext2D | null = null; + private readyState = false; + + async ready(): Promise { + if (this.readyState) return; + if (typeof document === "undefined") { + this.readyState = true; + return; + } + await document.fonts.ready; + this.canvas = document.createElement("canvas"); + this.context = this.canvas.getContext("2d"); + this.readyState = true; + } + + measure(text: string, role: FontRole, config: PaperConfig): FontMeasurement { + const style = roleStyle(role, config); + const font = `${style.weight ?? "400"} ${style.sizePx}px ${JSON.stringify(style.family)}`; + const loaded = typeof document !== "undefined" && document.fonts.check(font, text); + if (!this.context) { + const fallback = new DeterministicFontMeasurementProvider(); + return { ...fallback.measure(text, role, config), loaded: this.readyState && loaded, fallback: true }; + } + this.context.font = font; + const metrics = this.context.measureText(text); + const heightMm = style.sizePx * 25.4 / 96 * 1.15; + return { + text, + role, + widthMm: Math.max(0.5, metrics.width * 25.4 / 96), + heightMm, + baselineMm: heightMm * 0.82, + family: style.family, + loaded: this.readyState && loaded, + fallback: !loaded + }; + } +} + +export function createDefaultFontMeasurementProvider(): FontMeasurementProvider { + return typeof document === "undefined" ? new DeterministicFontMeasurementProvider() : new BrowserFontMeasurementProvider(); +} diff --git a/src/layout/layout-engine.ts b/src/layout/layout-engine.ts new file mode 100644 index 0000000..e84912d --- /dev/null +++ b/src/layout/layout-engine.ts @@ -0,0 +1,373 @@ +import { stableId, type ManualFontOverride, type ScoreDocument, type ScoreEvent, type ScoreMeasure, type StableId } from "../domain/score-model.js"; +import { createDefaultFontMeasurementProvider, type FontMeasurementProvider } from "./font-measure.js"; +import type { + AttachmentLayout, + FontMeasurement, + LayoutSnapshot, + LayoutWarning, + MeasureLayout, + MmRect, + PageLayout, + PageObjectLayout, + PaperConfig, + ScoreLineLayout, + SymbolLayout +} from "./types.js"; + +export const DEFAULT_PAPER_CONFIG: PaperConfig = { + widthMm: 210, + heightMm: 297, + marginsMm: { top: 20, right: 20, bottom: 20, left: 20 }, + titleHeightMm: 15, + lineHeightMm: 35, + lineGapMm: 4, + baseGapMm: 2.2, + measureGapMm: 4, + minimumMeasureWidthMm: 48, + alignment: "left", + dpi: 300, + fontRoles: { + note: { family: "Noto Sans", sizePx: 18, weight: "400" }, + lyric: { family: "Noto Sans CJK SC", sizePx: 13, weight: "400" }, + title: { family: "Noto Sans CJK SC", sizePx: 20, weight: "600" }, + text: { family: "Noto Sans CJK SC", sizePx: 12, weight: "400" }, + special: { family: "Noto Sans CJK SC", sizePx: 13, weight: "400" } + } +}; + +interface MeasurePlan { + measure: ScoreMeasure; + voiceId: StableId; + voiceIndex: number; + width: number; + symbolMetrics: Array<{ event: ScoreEvent; text: string; font: FontMeasurement; decorationText: string[]; chordText: string[]; width: number }>; + overflow: boolean; + breakAfter: boolean; +} + +interface LinePlan { + measures: MeasurePlan[]; + naturalWidth: number; +} + +function cloneConfig(config: PaperConfig): PaperConfig { + return structuredClone(config); +} + +function eventText(event: ScoreEvent): string { + if (event.source?.raw) return event.source.raw; + if (event.kind === "rest") return "0"; + if (event.kind === "note") return event.pitch?.degree === null || event.pitch?.degree === undefined ? "•" : `${event.pitch.accidental < 0 ? "b" : event.pitch.accidental > 0 ? "#" : ""}${event.pitch.degree}`; + return event.text ?? "·"; +} + +function eventRole(event: ScoreEvent): "note" | "text" | "special" { + return event.kind === "note" || event.kind === "rest" ? "note" : event.kind === "text" ? "text" : "special"; +} + +function applyFontOverride(font: FontMeasurement, override: ManualFontOverride | undefined): FontMeasurement { + if (!override) return font; + const scaleX = override.scaleX ?? 1; + const scaleY = override.scaleY ?? 1; + return { + ...font, + role: override.role === "note" || override.role === "text" || override.role === "title" || override.role === "lyric" || override.role === "special" ? override.role : font.role, + widthMm: font.widthMm * scaleX, + heightMm: font.heightMm * scaleY, + baselineMm: font.baselineMm * scaleY + }; +} + +function contentRect(config: PaperConfig): MmRect { + return { + x: config.marginsMm.left, + y: config.marginsMm.top, + width: config.widthMm - config.marginsMm.left - config.marginsMm.right, + height: config.heightMm - config.marginsMm.top - config.marginsMm.bottom + }; +} + +function musicalMeasures(document: ScoreDocument): Array<{ measure: ScoreMeasure; voiceId: StableId; voiceIndex: number }> { + const indexed = document.voices.map((voice, voiceIndex) => ({ voice, voiceIndex })); + const order = document.manualLayout.voiceOrder ?? []; + indexed.sort((left, right) => { + const leftOrder = order.indexOf(left.voice.id); + const rightOrder = order.indexOf(right.voice.id); + if (leftOrder < 0 && rightOrder < 0) return left.voiceIndex - right.voiceIndex; + if (leftOrder < 0) return 1; + if (rightOrder < 0) return -1; + return leftOrder - rightOrder; + }); + return indexed.flatMap(({ voice, voiceIndex }) => voice.visible && voice.measures.some((measure) => measure.events.some((event) => event.duration)) + ? voice.measures.map((measure) => ({ measure, voiceId: voice.id, voiceIndex })) + : []); +} + +function fingerprint(config: PaperConfig, documentId: StableId, pageCount: number, measures: MeasureLayout[]): string { + const input = JSON.stringify({ config, documentId, pageCount, measures: measures.map(({ id, page, line, x, y, width }) => ({ id, page, line, x, y, width })) }); + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function newPage(pageNumber: number, content: MmRect, config: PaperConfig): PageLayout { + return { + pageNumber, + widthMm: config.widthMm, + heightMm: config.heightMm, + contentRectMm: content, + titleRectMm: { x: content.x, y: content.y, width: content.width, height: config.titleHeightMm }, + lines: [], + measures: [], + symbols: [], + attachments: [], + pageObjects: [] + }; +} + +function addWarning(warnings: LayoutWarning[], code: string, message: string, objectId: StableId | null, page: number | null, line: number | null, severity: LayoutWarning["severity"] = "warning"): void { + warnings.push({ code, severity, message, objectId, page, line }); +} + +function makePlans(document: ScoreDocument, config: PaperConfig, provider: FontMeasurementProvider, warnings: LayoutWarning[], fontMeasurements: FontMeasurement[]): MeasurePlan[] { + return musicalMeasures(document).map(({ measure, voiceId, voiceIndex }) => { + const symbolMetrics = measure.events.map((event) => { + const text = eventText(event); + const role = eventRole(event); + const measured = provider.measure(text, role, config); + const voice = document.voices.find((candidate) => candidate.id === voiceId); + const voiceOverride = voice?.fontRole ? { role: voice.fontRole } : undefined; + const font = applyFontOverride(measured, document.manualLayout.fontOverrides?.[event.id] ?? document.manualLayout.fontOverrides?.[voiceId] ?? voiceOverride); + fontMeasurements.push(font); + if (font.fallback || !font.loaded) addWarning(warnings, "FONT_FALLBACK", `字体角色 ${role} 未确认加载,已使用可测量替代路径。`, event.id, null, null); + const decorationText = event.decorations?.map((decoration) => decoration.raw) ?? []; + const chordText = event.chord?.slice(1).map((pitch) => `${pitch.accidental < 0 ? "b" : pitch.accidental > 0 ? "#" : ""}${pitch.degree ?? ""}`) ?? []; + return { event, text, font, decorationText, chordText, width: event.hidden ? 0 : Math.max(2.5, font.widthMm) }; + }); + const naturalWidth = Math.max(config.minimumMeasureWidthMm, symbolMetrics.reduce((total, item) => total + item.width, 0) + Math.max(0, symbolMetrics.length - 1) * config.baseGapMm + config.baseGapMm * 2); + const manualWidth = naturalWidth + (document.manualLayout.measureSpacing?.[measure.id] ?? 0); + const width = Math.max(config.minimumMeasureWidthMm, manualWidth); + const overflow = width > contentRect(config).width; + if (overflow) addWarning(warnings, "MEASURE_OVERFLOW", `不可拆小节 ${measure.id} 宽度 ${width.toFixed(2)}mm 超过版心。`, measure.id, null, null, "error"); + return { measure, voiceId, voiceIndex, width, symbolMetrics, overflow, breakAfter: document.manualLayout.forcedBreakAfterMeasureIds.includes(measure.id) }; + }); +} + +function groupPlans(plans: MeasurePlan[], config: PaperConfig): LinePlan[] { + const width = contentRect(config).width; + const groups: LinePlan[] = []; + let current: MeasurePlan[] = []; + let currentWidth = 0; + for (const plan of plans) { + const proposed = current.length ? currentWidth + config.measureGapMm + plan.width : plan.width; + if (current.length && (proposed > width || current[current.length - 1].breakAfter)) { + groups.push({ measures: current, naturalWidth: currentWidth }); + current = []; + currentWidth = 0; + } + current.push(plan); + currentWidth = current.length === 1 ? plan.width : currentWidth + config.measureGapMm + plan.width; + } + if (current.length) groups.push({ measures: current, naturalWidth: currentWidth }); + return groups; +} + +function placeSymbols(document: ScoreDocument, page: PageLayout, line: ScoreLineLayout, plan: MeasurePlan, x: number, snapshotSymbols: Record): void { + const manualPosition = document.manualLayout.measurePositions?.[plan.measure.id] ?? { x: 0, y: 0 }; + const measureX = x + manualPosition.x; + const measureY = line.y + manualPosition.y + (document.manualLayout.voiceSpacing?.[plan.voiceId] ?? 0); + const measureLayout: MeasureLayout = { + id: plan.measure.id, + ordinal: plan.measure.ordinal, + page: page.pageNumber, + line: line.line, + x: measureX, + y: measureY, + width: plan.width, + height: line.height, + symbolIds: [], + overflow: plan.overflow, + voiceId: plan.voiceId, + locked: document.manualLayout.lockedMeasureIds.includes(plan.measure.id), + ...(plan.measure.barlineBefore ? { barlineBefore: plan.measure.barlineBefore } : {}), + ...(plan.measure.barlineAfter ? { barlineAfter: plan.measure.barlineAfter } : {}) + }; + let cursor = measureX + 2; + for (const item of plan.symbolMetrics) { + const manualObjectPosition = document.manualLayout.objectPositions?.[item.event.id] ?? { x: 0, y: 0 }; + const symbol: SymbolLayout = { + id: item.event.id, + kind: item.event.kind, + page: page.pageNumber, + line: line.line, + x: cursor + manualObjectPosition.x, + y: measureY + line.height / 2 - item.font.heightMm / 2 + manualObjectPosition.y, + width: item.width, + height: item.font.heightMm, + baseline: measureY + line.height / 2 + item.font.baselineMm / 2, + visible: !item.event.hidden, + text: item.text, + measureId: plan.measure.id, + voiceId: plan.voiceId, + font: item.font, + decorationText: item.decorationText, + chordText: item.chordText, + hidden: item.event.hidden, + privateGlyph: item.event.privateGlyph ?? null + }; + page.symbols.push(symbol); + snapshotSymbols[symbol.id] = symbol; + measureLayout.symbolIds.push(symbol.id); + cursor += item.width + 2.2 + manualObjectPosition.x; + } + page.measures.push(measureLayout); +} + +function layoutPageObjects(document: ScoreDocument, page: PageLayout, config: PaperConfig, warnings: LayoutWarning[]): PageObjectLayout[] { + return document.pageObjects.map((object, index) => { + const position = object.position ?? document.manualLayout.objectPositions[object.id] ?? { x: page.contentRectMm.x, y: page.contentRectMm.y + page.contentRectMm.height - 8 - index * 5 }; + const width = Math.max(10, object.raw.length * 1.8); + const height = 5; + if (!object.position && !document.manualLayout.objectPositions[object.id]) addWarning(warnings, "PAGE_OBJECT_POSITION_UNRESOLVED", `页面对象 ${object.id} 未提供页面坐标,使用诊断性占位。`, object.id, page.pageNumber, null); + if (position.x < 0 || position.y < 0 || position.x + width > config.widthMm || position.y + height > config.heightMm) addWarning(warnings, "PAGE_OBJECT_OVERFLOW", `页面对象 ${object.id} 越过 A4 页面边界。`, object.id, page.pageNumber, null); + return { id: object.id, page: page.pageNumber, x: position.x, y: position.y, width, height, raw: object.raw, kind: object.kind, visible: object.visible !== false }; + }); +} + +export async function layoutDocument(document: ScoreDocument, config: PaperConfig = DEFAULT_PAPER_CONFIG, provider: FontMeasurementProvider = createDefaultFontMeasurementProvider()): Promise { + await provider.ready(); + const resolvedConfig = cloneConfig(config); + const content = contentRect(resolvedConfig); + const warnings: LayoutWarning[] = []; + const fontMeasurements: FontMeasurement[] = []; + const snapshotMeasures: Record = {}; + const snapshotSymbols: Record = {}; + const snapshotAttachments: Record = {}; + const plans = makePlans(document, resolvedConfig, provider, warnings, fontMeasurements); + const groups = groupPlans(plans, resolvedConfig); + const pages: PageLayout[] = []; + let page = newPage(1, content, resolvedConfig); + pages.push(page); + let y = content.y + resolvedConfig.titleHeightMm; + let pageLine = 0; + + for (const [groupIndex, group] of groups.entries()) { + if (y + resolvedConfig.lineHeightMm > content.y + content.height + 0.0001) { + page = newPage(pages.length + 1, content, resolvedConfig); + pages.push(page); + y = content.y; + pageLine = 0; + } + const lineId = stableId("line", `${document.documentId}:${groupIndex}:${page.pageNumber}`); + const line: ScoreLineLayout = { id: lineId, page: page.pageNumber, line: pageLine, x: content.x, y, width: group.naturalWidth, height: resolvedConfig.lineHeightMm, measureIds: [] }; + pageLine += 1; + const extra = resolvedConfig.alignment === "justify" && groupIndex < groups.length - 1 && group.measures.length > 1 ? Math.max(0, content.width - group.naturalWidth) / (group.measures.length - 1) : 0; + let x = content.x; + for (const [measureIndex, plan] of group.measures.entries()) { + placeSymbols(document, page, line, plan, x, snapshotSymbols); + const measure = page.measures[page.measures.length - 1]; + snapshotMeasures[measure.id] = measure; + line.measureIds.push(measure.id); + x += plan.width + resolvedConfig.measureGapMm + extra; + if (measureIndex === group.measures.length - 1) line.width = Math.max(plan.width + x - plan.width - content.x, group.naturalWidth + extra * Math.max(0, group.measures.length - 1)); + } + page.lines.push(line); + y += resolvedConfig.lineHeightMm; + } + + for (const attachment of document.attachments) { + const target = attachment.anchor.startId ? snapshotSymbols[attachment.anchor.startId] : undefined; + const endTarget = attachment.anchor.endId ? snapshotSymbols[attachment.anchor.endId] : undefined; + if (!target || attachment.status !== "attached" || attachment.visible === false) { + const layout: AttachmentLayout = { id: attachment.id, page: null, line: null, x: 0, y: 0, width: 0, height: 0, visible: false, raw: attachment.raw, lines: attachment.lyric?.lines, kind: attachment.kind }; + snapshotAttachments[attachment.id] = layout; + if (attachment.visible !== false) addWarning(warnings, "ORPHANED_ATTACHMENT", `附件 ${attachment.id} 没有可用的已解析锚点,未绘制到正式谱面。`, attachment.id, null, null); + continue; + } + const textLines = attachment.lyric?.lines.length ? attachment.lyric.lines : [attachment.raw]; + const height = Math.max(4, textLines.length * 4); + const attachmentOffset = document.manualLayout.lyricOffsets?.[attachment.id] ?? { x: 0, y: 0 }; + const firstPage = target.page; + const lastPage = endTarget ? Math.max(firstPage, endTarget.page) : firstPage; + const segmentSpecs: Array<{ page: number; line: number; x: number; y: number; width: number }> = []; + for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) { + const currentPage = pages[pageNumber - 1]; + if (!currentPage) continue; + const firstLine = pageNumber === firstPage ? target.line : 0; + const lastLine = endTarget ? (pageNumber === lastPage ? endTarget.line : Math.max(0, currentPage.lines.length - 1)) : target.line; + for (const lineLayout of currentPage.lines.filter((candidate) => candidate.line >= firstLine && candidate.line <= lastLine)) { + const isFirst = pageNumber === firstPage && lineLayout.line === target.line; + const isLast = Boolean(endTarget && pageNumber === lastPage && lineLayout.line === endTarget.line); + const spansSameLine = isFirst && isLast && endTarget?.page === target.page && endTarget.line === target.line; + segmentSpecs.push({ + page: pageNumber, + line: lineLayout.line, + x: (isFirst ? target.x : currentPage.contentRectMm.x) + attachmentOffset.x, + y: (isFirst ? target.y - height - 1 : lineLayout.y + 2) + attachmentOffset.y, + width: spansSameLine ? Math.max(target.width, endTarget.x + endTarget.width - target.x) : Math.max(20, lineLayout.width - 4) + }); + } + } + if (!segmentSpecs.length) segmentSpecs.push({ page: firstPage, line: target.line, x: target.x + attachmentOffset.x, y: target.y - height - 1 + attachmentOffset.y, width: Math.max(target.width, textLines.join(" ").length * 1.5) }); + const syllableMapping = attachment.kind === "Lyric" && Boolean(attachment.lyric?.syllableEventIds?.some((eventId) => eventId)); + for (const [segmentIndex, spec] of segmentSpecs.entries()) { + const layout: AttachmentLayout = { + id: attachment.id, + page: spec.page, + line: spec.line, + x: spec.x, + y: spec.y, + width: spec.width, + height, + visible: true, + raw: textLines.join(" "), + lines: textLines, + kind: attachment.kind, + continued: segmentSpecs.length > 1, + segmentIndex, + segmentCount: segmentSpecs.length, + offset: attachmentOffset, + syllableMapping, + syllables: syllableMapping ? (attachment.lyric?.syllables ?? []).flatMap((text, syllableIndex) => { + const eventId = attachment.lyric?.syllableEventIds?.[syllableIndex] ?? null; + const symbol = eventId ? snapshotSymbols[eventId] : undefined; + return symbol && symbol.page === spec.page && symbol.line === spec.line ? [{ text, eventId, page: spec.page, line: spec.line, x: symbol.x + attachmentOffset.x, y: spec.y + 4 }] : []; + }) : undefined + }; + if (segmentIndex === 0) snapshotAttachments[attachment.id] = layout; + pages[spec.page - 1].attachments.push(layout); + } + } + + for (const currentPage of pages) currentPage.pageObjects.push(...layoutPageObjects(document, currentPage, resolvedConfig, warnings)); + const allMeasures = pages.flatMap((currentPage) => currentPage.measures); + return { + version: 1, + sourceDocumentId: document.documentId, + documentTitle: document.title.title, + pageSizeMm: { width: 210, height: 297 }, + contentRectMm: content, + config: resolvedConfig, + pages, + measures: snapshotMeasures, + symbols: snapshotSymbols, + attachments: snapshotAttachments, + warnings, + fontMeasurements, + deterministicKey: fingerprint(resolvedConfig, document.documentId, pages.length, allMeasures) + }; +} + +export function layoutSummary(snapshot: LayoutSnapshot): { pageCount: number; lineCount: number; measureCount: number; symbolCount: number; overflowCount: number; warningCount: number } { + return { + pageCount: snapshot.pages.length, + lineCount: snapshot.pages.reduce((count, page) => count + page.lines.length, 0), + measureCount: Object.keys(snapshot.measures).length, + symbolCount: Object.keys(snapshot.symbols).length, + overflowCount: snapshot.warnings.filter((warning) => warning.code === "MEASURE_OVERFLOW" || warning.code === "PAGE_OBJECT_OVERFLOW").length, + warningCount: snapshot.warnings.length + }; +} diff --git a/src/layout/svg-renderer.ts b/src/layout/svg-renderer.ts new file mode 100644 index 0000000..7672b3f --- /dev/null +++ b/src/layout/svg-renderer.ts @@ -0,0 +1,86 @@ +import type { AttachmentLayout, LayoutSnapshot, PageLayout, PageObjectLayout, SymbolLayout } from "./types.js"; + +const MM_SCALE = 100; + +function esc(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +function u(value: number): string { + return (value * MM_SCALE).toFixed(2).replace(/\.00$/, ""); +} + +function renderTitle(page: PageLayout, snapshot: LayoutSnapshot): string { + if (page.pageNumber !== 1 || !snapshot.pages[0]) return ""; + const title = snapshot.pages[0].titleRectMm; + const text = snapshot.documentTitle || snapshot.sourceDocumentId; + return `${esc(text)}`; +} + +function renderSymbol(symbol: SymbolLayout): string { + const attrs = `data-id="${esc(symbol.id)}" data-kind="${esc(symbol.kind)}" data-measure-id="${esc(symbol.measureId)}"${symbol.voiceId ? ` data-voice-id="${esc(symbol.voiceId)}"` : ""}`; + const hidden = symbol.hidden ? " visibility=\"hidden\"" : ""; + const note = `${esc(symbol.text)}`; + const decorations = (symbol.decorationText ?? []).map((text, index) => `${esc(text)}`).join(""); + const chord = (symbol.chordText ?? []).map((text, index) => `${esc(text)}`).join(""); + return `${note}${decorations}${chord}`; +} + +function renderBarline(x: number, y: number, height: number, style: string, id: string): string { + if (style === "unknown") return ""; + const dashed = style === "dashed" ? ` stroke-dasharray="2 1"` : ""; + const double = style === "||" || style === "::" || style === ":|" || style === "|:"; + const lines = `${double ? `` : ""}`; + const repeat = style === ":|" || style === "|:" || style === "::" ? `` : ""; + return `${lines}${repeat}`; +} + +function renderMeasure(measure: import("./types.js").MeasureLayout): string { + const y = measure.y + 2; + const height = Math.max(4, measure.height - 4); + const attrs = `data-layer="measure" data-id="${esc(measure.id)}" data-kind="measure" data-locked="${measure.locked ? "true" : "false"}"${measure.voiceId ? ` data-voice-id="${esc(measure.voiceId)}"` : ""}`; + const boundaries = `${measure.barlineBefore ? renderBarline(measure.x, y, height, measure.barlineBefore, `${measure.id}-before`) : ""}${measure.barlineAfter ? renderBarline(measure.x + measure.width, y, height, measure.barlineAfter, `${measure.id}-after`) : ""}`; + return `${boundaries}`; +} + +function renderAttachment(attachment: AttachmentLayout): string { + const continuation = attachment.continued ? ` data-continuation="true" data-segment="${attachment.segmentIndex ?? 0}"` : ""; + const lines = attachment.lines?.length ? attachment.lines : [attachment.raw]; + const kind = attachment.kind ?? "attachment"; + const curve = /^(?:Arc|Extend|Branch|Slur|Tie)$/i.test(kind) + ? `` + : ""; + const text = /^(?:Arc|Extend|Branch|Slur|Tie)$/i.test(kind) + ? "" + : attachment.syllableMapping + ? (attachment.syllables ?? []).map((syllable) => `${esc(syllable.text)}`).join("") + : lines.map((line, index) => `${esc(line)}`).join(""); + return `${curve}${text}`; +} + +function renderPageObject(object: PageObjectLayout): string { + const hidden = object.visible === false ? " visibility=\"hidden\"" : ""; + return `${esc(object.raw)}`; +} + +export function renderPageSvg(snapshot: LayoutSnapshot, page: PageLayout, includeAssistiveLayer = false): string { + const symbols = page.symbols.map(renderSymbol).join(""); + const measures = page.measures.map(renderMeasure).join(""); + const attachments = page.attachments.map(renderAttachment).join(""); + const pageObjects = page.pageObjects.map(renderPageObject).join(""); + const assistive = includeAssistiveLayer ? `辅助层:${snapshot.warnings.length} 条布局诊断` : ""; + return `JPW7 page ${page.pageNumber}${renderTitle(page, snapshot)}${measures}${symbols}${attachments}${pageObjects}${assistive}`; +} + +export function renderSnapshotSvg(snapshot: LayoutSnapshot, includeAssistiveLayer = false): string[] { + return snapshot.pages.map((page) => renderPageSvg(snapshot, page, includeAssistiveLayer)); +} + +export function probeSvg(svg: string): import("./types.js").SvgProbe { + const width = svg.match(/\bwidth="([^"]+)"/)?.[1] ?? ""; + const height = svg.match(/\bheight="([^"]+)"/)?.[1] ?? ""; + const viewBox = svg.match(/\bviewBox="([^"]+)"/)?.[1] ?? ""; + const pageNumber = Number(svg.match(/data-page="(\d+)"/)?.[1] ?? 0); + const dataIds = [...svg.matchAll(/data-id="([^"]+)"/g)].map((match) => match[1]); + return { width, height, viewBox, pageNumber, dataIds, hasAssistiveLayer: svg.includes('data-layer="assistive"'), hasScoreLayer: svg.includes('data-layer="score"') }; +} diff --git a/src/layout/types.ts b/src/layout/types.ts new file mode 100644 index 0000000..3b71ad9 --- /dev/null +++ b/src/layout/types.ts @@ -0,0 +1,175 @@ +import type { StableId } from "../domain/score-model.js"; + +export type TextAlignment = "left" | "justify"; +export type FontRole = "note" | "lyric" | "title" | "text" | "special"; + +export interface MmRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PaperConfig { + widthMm: 210; + heightMm: 297; + marginsMm: { top: number; right: number; bottom: number; left: number }; + titleHeightMm: number; + lineHeightMm: number; + lineGapMm: number; + baseGapMm: number; + measureGapMm: number; + minimumMeasureWidthMm: number; + alignment: TextAlignment; + dpi: number; + fontRoles: Record; +} + +export interface FontMeasurement { + text: string; + role: FontRole; + widthMm: number; + heightMm: number; + baselineMm: number; + family: string; + loaded: boolean; + fallback: boolean; +} + +export interface LayoutWarning { + code: string; + severity: "warning" | "error"; + message: string; + objectId: StableId | null; + page: number | null; + line: number | null; +} + +export interface SymbolLayout { + id: StableId; + kind: string; + page: number; + line: number; + x: number; + y: number; + width: number; + height: number; + baseline: number; + visible: boolean; + text: string; + measureId: StableId; + voiceId?: StableId; + font: FontMeasurement | null; + decorationText?: string[]; + chordText?: string[]; + hidden?: boolean; + privateGlyph?: { fontRole: string | null; codePoint: number | null; raw: string } | null; +} + +export interface MeasureLayout { + id: StableId; + ordinal: number; + page: number; + line: number; + x: number; + y: number; + width: number; + height: number; + symbolIds: StableId[]; + overflow: boolean; + voiceId?: StableId; + locked?: boolean; + barlineBefore?: string; + barlineAfter?: string; +} + +export interface ScoreLineLayout { + id: StableId; + page: number; + line: number; + x: number; + y: number; + width: number; + height: number; + measureIds: StableId[]; +} + +export interface LyricSyllableLayout { + text: string; + eventId: StableId | null; + page: number; + line: number; + x: number; + y: number; +} + +export interface AttachmentLayout { + id: StableId; + page: number | null; + line: number | null; + x: number; + y: number; + width: number; + height: number; + visible: boolean; + raw: string; + lines?: string[]; + kind?: string; + continued?: boolean; + segmentIndex?: number; + segmentCount?: number; + offset?: { x: number; y: number }; + syllableMapping?: boolean; + syllables?: LyricSyllableLayout[]; +} + +export interface PageObjectLayout { + id: StableId; + page: number; + x: number; + y: number; + width: number; + height: number; + raw: string; + kind?: string; + visible?: boolean; +} + +export interface PageLayout { + pageNumber: number; + widthMm: number; + heightMm: number; + contentRectMm: MmRect; + titleRectMm: MmRect; + lines: ScoreLineLayout[]; + measures: MeasureLayout[]; + symbols: SymbolLayout[]; + attachments: AttachmentLayout[]; + pageObjects: PageObjectLayout[]; +} + +export interface LayoutSnapshot { + version: 1; + sourceDocumentId: StableId; + documentTitle: string; + pageSizeMm: { width: 210; height: 297 }; + contentRectMm: MmRect; + config: PaperConfig; + pages: PageLayout[]; + measures: Record; + symbols: Record; + attachments: Record; + warnings: LayoutWarning[]; + fontMeasurements: FontMeasurement[]; + deterministicKey: string; +} + +export interface SvgProbe { + width: string; + height: string; + viewBox: string; + pageNumber: number; + dataIds: string[]; + hasAssistiveLayer: boolean; + hasScoreLayer: boolean; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..de10610 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +import { mountEditor } from "./app/editor.js"; + +const app = document.querySelector("#app"); + +if (!app) { + throw new Error("Editor mount target #app was not found"); +} + +mountEditor(app); diff --git a/src/output/output.ts b/src/output/output.ts new file mode 100644 index 0000000..a1adcb9 --- /dev/null +++ b/src/output/output.ts @@ -0,0 +1,132 @@ +import { renderSnapshotSvg } from "../layout/svg-renderer.js"; +import type { LayoutSnapshot } from "../layout/types.js"; + +export interface PixelDimensions { + width: number; + height: number; + dpi: number; +} + +export interface OutputGeometry { + widthMm: 210; + heightMm: 297; + pageCount: number; + png: PixelDimensions; +} + +export interface PrintResult { + printed: boolean; + pageCount: number; + widthMm: 210; + heightMm: 297; + reason?: string; +} + +const MM_PER_INCH = 25.4; + +export function pixelsForMillimeters(widthMm: number, heightMm: number, dpi: number): PixelDimensions { + const safeDpi = Number.isFinite(dpi) && dpi > 0 ? dpi : 300; + return { width: Math.round(widthMm / MM_PER_INCH * safeDpi), height: Math.round(heightMm / MM_PER_INCH * safeDpi), dpi: safeDpi }; +} + +export function outputGeometry(snapshot: LayoutSnapshot, dpi = snapshot.config.dpi): OutputGeometry { + return { + widthMm: 210, + heightMm: 297, + pageCount: snapshot.pages.length, + png: pixelsForMillimeters(210, 297, dpi) + }; +} + +export function exportSnapshotSvg(snapshot: LayoutSnapshot): string[] { + // The formal export never includes selection, debug, or assistive layers. + return renderSnapshotSvg(snapshot, false); +} + +function requireBrowserDocument(): Document { + if (typeof document === "undefined") throw new Error("PNG 输出需要浏览器文档环境。"); + return document; +} + +function canvasBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("浏览器无法编码 PNG。")), "image/png"); + }); +} + +export async function renderSvgPageToPng(svgText: string, dpi = 300): Promise { + const owner = requireBrowserDocument(); + const dimensions = pixelsForMillimeters(210, 297, dpi); + // Chromium can leave a blob-backed SVG image pending when it references local/private font families. + // A UTF-8 data URL keeps the same SVG content but lets the image decoder finish deterministically. + const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`; + try { + const image = new Image(); + image.decoding = "async"; + await new Promise((resolve, reject) => { + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => reject(new Error("SVG 页面无法栅格化为 PNG。")), { once: true }); + image.src = url; + }); + const canvas = owner.createElement("canvas"); + canvas.width = dimensions.width; + canvas.height = dimensions.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("浏览器不支持 Canvas 2D 输出。"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return await canvasBlob(canvas); + } finally { + // data URLs do not require revocation. + } +} + +export async function renderSnapshotPageToPng(snapshot: LayoutSnapshot, pageIndex = 0, dpi = snapshot.config.dpi): Promise { + const svg = exportSnapshotSvg(snapshot)[pageIndex]; + if (!svg) throw new Error(`找不到第 ${pageIndex + 1} 页谱面。`); + return renderSvgPageToPng(svg, dpi); +} + +export async function renderSnapshotToPng(snapshot: LayoutSnapshot, dpi = snapshot.config.dpi): Promise { + const svgs = exportSnapshotSvg(snapshot); + // Decode pages concurrently so large multi-page scores do not look hung in Chromium. + return await Promise.all(svgs.map((svg) => renderSvgPageToPng(svg, dpi))); +} + +export function buildPrintableHtml(snapshot: LayoutSnapshot): string { + const pages = exportSnapshotSvg(snapshot).map((svg, index) => `
${svg}
`).join(""); + return `${escapeHtml(snapshot.documentTitle || "JPW7 谱面")}${pages}`; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +/** Print from the live document so the same LayoutSnapshot is used on screen and paper. */ +export function printSnapshot(snapshot: LayoutSnapshot, ownerDocument?: Document, ownerWindow?: Window): PrintResult { + const targetDocument = ownerDocument ?? (typeof document === "undefined" ? undefined : document); + const targetWindow = ownerWindow ?? (typeof window === "undefined" ? undefined : window); + if (!targetDocument || !targetWindow) return { printed: false, pageCount: snapshot.pages.length, widthMm: 210, heightMm: 297, reason: "当前环境没有浏览器打印接口。" }; + const root = targetDocument.createElement("div"); + root.className = "jpw7-print-root"; + root.setAttribute("aria-hidden", "true"); + const svgParser = new DOMParser(); + for (const [index, svgText] of exportSnapshotSvg(snapshot).entries()) { + const page = targetDocument.createElement("section"); + page.className = "jpw7-print-page"; + page.dataset.page = String(index + 1); + const parsed = svgParser.parseFromString(svgText, "image/svg+xml").documentElement; + if (parsed.localName !== "svg") throw new Error("正式 SVG 输出无效,已阻止打印。"); + page.appendChild(targetDocument.importNode(parsed, true)); + root.appendChild(page); + } + targetDocument.body.appendChild(root); + const cleanup = () => root.remove(); + targetWindow.addEventListener("afterprint", cleanup, { once: true }); + targetWindow.setTimeout(cleanup, 1500); + targetWindow.print(); + return { printed: true, pageCount: snapshot.pages.length, widthMm: 210, heightMm: 297 }; +} + +export const printLayoutSnapshot = printSnapshot; diff --git a/src/playback/playback.ts b/src/playback/playback.ts new file mode 100644 index 0000000..539d947 --- /dev/null +++ b/src/playback/playback.ts @@ -0,0 +1,793 @@ +import { + rational, + type Rational, + type ScoreDocument, + type ScoreEvent, + type StableId +} from "../domain/score-model.js"; + +export type PlaybackState = "stopped" | "playing" | "paused"; +export type Waveform = "sine" | "triangle" | "square" | "sawtooth"; + +export interface PlaybackVoiceControl { + muted: boolean; + solo: boolean; + volume: number; + waveform: Waveform; +} + +export interface PlaybackNoteEvent { + kind: "note"; + eventId: StableId; + voiceId: StableId; + voiceIndex: number; + startTick: number; + durationTicks: number; + gateTicks: number; + midi: number; + velocity: number; + sourceEvent: ScoreEvent; +} + +export interface PlaybackRestEvent { + kind: "rest"; + eventId: StableId; + voiceId: StableId; + voiceIndex: number; + startTick: number; + durationTicks: number; + sourceEvent: ScoreEvent; +} + +export type PlaybackEvent = PlaybackNoteEvent | PlaybackRestEvent; + +export interface PlaybackControlEvent { + tick: number; + voiceId: StableId; + type: "tempo" | "meter" | "key" | "velocity" | "gate" | "navigation"; + value: string | number | { numerator: number; denominator: number }; +} + +export interface PlaybackTimeline { + readonly version: 1; + readonly ticksPerBeat: number; + readonly tempoBpm: number; + readonly meter: { numerator: number; denominator: number }; + readonly key: string; + readonly voiceIds: StableId[]; + readonly events: PlaybackEvent[]; + readonly durationTicks: number; + readonly warnings: string[]; + readonly voiceControls: Record; + readonly controlEvents?: PlaybackControlEvent[]; + readonly tempoChanges?: Array<{ tick: number; bpm: number }>; + readonly meterChanges?: Array<{ tick: number; meter: { numerator: number; denominator: number } }>; + readonly keyChanges?: Array<{ tick: number; key: string }>; + readonly loopIterations?: number; + secondsAtTick(tick: number): number; + ticksAtSeconds(seconds: number): number; + eventsAtTick(tick: number): PlaybackEvent[]; + stateAtTick?(tick: number): { tempoBpm: number; meter: { numerator: number; denominator: number }; key: string }; +} + +export interface CompilePlaybackOptions { + ticksPerBeat?: number; + defaultTempoBpm?: number; + defaultMeter?: { numerator: number; denominator: number }; + defaultKey?: string; +} + +const DEFAULT_TICKS_PER_BEAT = 960; +const DEFAULT_TEMPO = 60; +const DEFAULT_METER = { numerator: 4, denominator: 4 }; +const DEFAULT_KEY = "C"; +const SCALE_OFFSETS = [0, 2, 4, 5, 7, 9, 11]; +const KEY_OFFSETS: Record = { + C: 0, + "C#": 1, + DB: 1, + D: 2, + "D#": 3, + EB: 3, + E: 4, + F: 5, + "F#": 6, + GB: 6, + G: 7, + "G#": 8, + AB: 8, + A: 9, + "A#": 10, + BB: 10, + B: 11 +}; + +function gcd(left: number, right: number): number { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) [a, b] = [b, a % b]; + return a || 1; +} + +function lcm(left: number, right: number): number { + return Math.abs(left / gcd(left, right) * right); +} + +function positiveInteger(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? Math.max(1, Math.round(value)) : fallback; +} + +function normalizeTempo(value: number | null | undefined, fallback = DEFAULT_TEMPO): number { + return Number.isFinite(value) && (value ?? 0) > 0 ? Math.min(400, Math.max(1, value as number)) : fallback; +} + +function parseMeter(value: string, fallback: { numerator: number; denominator: number }): { numerator: number; denominator: number } { + const match = value.trim().match(/^(\d+)\s*\/\s*(\d+)$/); + if (!match) return { ...fallback }; + const numerator = Number(match[1]); + const denominator = Number(match[2]); + return numerator > 0 && denominator > 0 ? { numerator, denominator } : { ...fallback }; +} + +function normalizeKey(value: string, fallback: string): string { + const match = value.trim().toUpperCase().match(/^(?:1\s*=\s*)?([A-G])([#B]?)/); + return match ? `${match[1]}${match[2]}` : fallback; +} + +function keyOffset(key: string): number { + return KEY_OFFSETS[key.toUpperCase()] ?? 0; +} + +function degreeToMidi(degree: number | null, accidental: -1 | 0 | 1, octave: number | null, key: string): number | null { + if (degree === null || degree < 1 || degree > 7) return null; + const octaveOffset = octave ?? 0; + return 60 + keyOffset(key) + SCALE_OFFSETS[degree - 1] + accidental + octaveOffset * 12; +} + +function allDurations(document: ScoreDocument): Rational[] { + const durations: Rational[] = []; + for (const voice of document.voices) for (const measure of voice.measures) for (const event of measure.events) { + if (event.duration && event.duration.numerator > 0) durations.push(event.duration); + } + return durations; +} + +function chooseTicksPerBeat(document: ScoreDocument, requested: number | undefined): number { + if (requested !== undefined) return positiveInteger(requested, DEFAULT_TICKS_PER_BEAT); + let result = DEFAULT_TICKS_PER_BEAT; + for (const duration of allDurations(document)) result = lcm(result, duration.denominator); + return result; +} + +function ticksFor(value: Rational, ticksPerBeat: number): number { + const numerator = value.numerator * ticksPerBeat; + if (numerator % value.denominator !== 0) return Math.round(numerator / value.denominator); + return numerator / value.denominator; +} + +function defaultVoiceControl(): PlaybackVoiceControl { + return { muted: false, solo: false, volume: 1, waveform: "sine" }; +} + +function secondsAtTickWithTempo(tick: number, ticksPerBeat: number, initialTempo: number, changes: Array<{ tick: number; bpm: number }>): number { + const target = Math.max(0, tick); + let cursor = 0; + let tempo = initialTempo; + let seconds = 0; + for (const change of changes) { + if (change.tick <= cursor) { tempo = change.bpm; continue; } + if (change.tick >= target) break; + seconds += (change.tick - cursor) / ticksPerBeat * 60 / tempo; + cursor = change.tick; + tempo = change.bpm; + } + return seconds + (target - cursor) / ticksPerBeat * 60 / tempo; +} + +function ticksAtSecondsWithTempo(seconds: number, ticksPerBeat: number, initialTempo: number, changes: Array<{ tick: number; bpm: number }>): number { + let remaining = Math.max(0, seconds); + let cursor = 0; + let tempo = initialTempo; + for (const change of changes) { + const segmentSeconds = (change.tick - cursor) / ticksPerBeat * 60 / tempo; + if (remaining <= segmentSeconds) return cursor + remaining * tempo / 60 * ticksPerBeat; + remaining -= segmentSeconds; + cursor = change.tick; + tempo = change.bpm; + } + return cursor + remaining * tempo / 60 * ticksPerBeat; +} + +function timelineObject( + ticksPerBeat: number, + tempoBpm: number, + meter: { numerator: number; denominator: number }, + key: string, + voiceIds: StableId[], + events: PlaybackEvent[], + durationTicks: number, + warnings: string[], + voiceControls: Record, + controlEvents: PlaybackControlEvent[] = [], + tempoChanges: Array<{ tick: number; bpm: number }> = [], + meterChanges: Array<{ tick: number; meter: { numerator: number; denominator: number } }> = [], + keyChanges: Array<{ tick: number; key: string }> = [], + loopIterations = 1 +): PlaybackTimeline { + const orderedTempoChanges = [...tempoChanges].sort((left, right) => left.tick - right.tick); + const timeline: PlaybackTimeline = { + version: 1, + ticksPerBeat, + tempoBpm, + meter, + key, + voiceIds, + events, + durationTicks, + warnings, + voiceControls, + controlEvents, + tempoChanges: orderedTempoChanges, + meterChanges, + keyChanges, + loopIterations, + secondsAtTick: (tick) => secondsAtTickWithTempo(tick, ticksPerBeat, tempoBpm, orderedTempoChanges), + ticksAtSeconds: (seconds) => ticksAtSecondsWithTempo(seconds, ticksPerBeat, tempoBpm, orderedTempoChanges), + eventsAtTick: (tick) => events.filter((event) => event.startTick === tick), + stateAtTick: (tick) => { + const tempo = orderedTempoChanges.filter((change) => change.tick <= tick).at(-1)?.bpm ?? tempoBpm; + const currentMeter = meterChanges.filter((change) => change.tick <= tick).at(-1)?.meter ?? meter; + const currentKey = keyChanges.filter((change) => change.tick <= tick).at(-1)?.key ?? key; + return { tempoBpm: tempo, meter: currentMeter, key: currentKey }; + } + }; + return timeline; +} + +type PlaybackMeasure = ScoreDocument["voices"][number]["measures"][number]; + +function rawEventText(event: ScoreEvent): string { + return [event.source?.raw ?? event.text ?? "", ...(event.decorations ?? []).map((decoration) => decoration.raw)].join(" "); +} + +function controlValues(event: ScoreEvent): { tempo: number | null; meter: { numerator: number; denominator: number } | null; key: string | null } { + const raw = rawEventText(event); + const tempo = raw.match(/(?:J|Tempo|BPM)\s*=?\s*(\d{1,3})/i)?.[1]; + const meter = raw.match(/(?:M|Meter)\s*=?\s*(\d+)\s*[/,:]\s*(\d+)/i); + const key = raw.match(/(?:K|Key)\s*=?\s*([A-G](?:#|b)?)/i)?.[1] ?? null; + return { + tempo: tempo ? normalizeTempo(Number(tempo), DEFAULT_TEMPO) : null, + meter: meter ? { numerator: Number(meter[1]), denominator: Number(meter[2]) } : null, + key: key ? normalizeKey(key, DEFAULT_KEY) : null + }; +} + +function navigationForMeasure(measure: PlaybackMeasure): { dc: boolean; ds: boolean; fine: boolean } { + const raw = measure.events.flatMap((event) => [rawEventText(event), event.text ?? ""]).join(" "); + return { + dc: /(?:^|[\\s{,])(?:DC|DaCapo)(?:$|[\\s},])/i.test(raw), + ds: /(?:^|[\\s{,])(?:DS|DalSegno)(?:$|[\\s},])/i.test(raw), + fine: /(?:^|[\\s{,])Fine(?:$|[\\s},])/i.test(raw) + }; +} + +function expandedMeasures(voice: ScoreDocument["voices"][number], warnings: string[]): Array<{ measure: PlaybackMeasure; pass: number }> { + const result: Array<{ measure: PlaybackMeasure; pass: number }> = []; + if (!voice.measures.length) return result; + let index = 0; + let pass = 1; + let repeatStart = 0; + let repeated = false; + let daCapo = false; + let dalSegno = false; + let iterations = 0; + const visited = new Set(); + const limit = Math.max(64, voice.measures.length * 8); + while (index >= 0 && index < voice.measures.length && iterations < limit) { + const state = `${index}:${pass}:${repeatStart}:${repeated}:${daCapo}:${dalSegno}`; + if (visited.has(state)) { + warnings.push(`声部 ${voice.id} 的反复/跳转状态形成循环,已在安全边界降级停止。`); + break; + } + visited.add(state); + const measure = voice.measures[index]; + if (measure.repeatStart) repeatStart = index; + const include = !measure.ending || measure.ending === pass; + if (include) result.push({ measure, pass }); + const navigation = navigationForMeasure(measure); + if (navigation.fine && (daCapo || dalSegno)) break; + if (measure.repeatEnd && !repeated) { + repeated = true; + pass = 2; + index = repeatStart; + iterations += 1; + continue; + } + if (navigation.dc && !daCapo) { + daCapo = true; + pass += 1; + index = 0; + iterations += 1; + continue; + } + if (navigation.ds && !dalSegno) { + dalSegno = true; + pass += 1; + index = repeatStart; + iterations += 1; + continue; + } + index += 1; + iterations += 1; + } + if (iterations >= limit) warnings.push(`声部 ${voice.id} 的反复路径超过安全迭代上限,已停止扩展。`); + return result; +} + +function dynamicsForEvent(event: ScoreEvent): { velocity: number; gateRatio: number } { + const raw = rawEventText(event).toLowerCase(); + const dynamic = raw.match(/(?:^|[\\s{,])(ppp|pp|mp|mf|ff|fff|p|f)(?:$|[\\s},])/i)?.[1]?.toLowerCase(); + const velocityMap: Record = { ppp: 32, pp: 45, p: 58, mp: 70, mf: 82, f: 96, ff: 108, fff: 118 }; + let velocity = event.velocity ?? (dynamic ? velocityMap[dynamic] : 80); + if (/(?:DunYin|accent|ZhongYin)/i.test(raw)) velocity += 15; + const gateRatio = /(?:staccato|DuanYin)/i.test(raw) ? 0.5 : /(?:HuanQi|breath)/i.test(raw) ? 0.75 : 1; + return { velocity: Math.min(127, Math.max(1, Math.round(velocity))), gateRatio }; +} + +function eventDurationTicks(event: ScoreEvent, ticksPerBeat: number): number { + if (!event.duration) return 0; + const base = ticksFor(event.duration, ticksPerBeat); + const ratio = event.tuplet?.ratio && event.tuplet.ratio > 1 ? (event.tuplet.ratio - 1) / event.tuplet.ratio : 1; + return Math.max(1, Math.round(base * ratio)); +} + +function addTempoChange(changes: Array<{ tick: number; bpm: number }>, tick: number, bpm: number): void { + const existing = changes.find((change) => change.tick === tick); + if (existing) existing.bpm = bpm; + else changes.push({ tick, bpm }); +} + +function addMeterChange(changes: Array<{ tick: number; meter: { numerator: number; denominator: number } }>, tick: number, meter: { numerator: number; denominator: number }): void { + if (!changes.some((change) => change.tick === tick && change.meter.numerator === meter.numerator && change.meter.denominator === meter.denominator)) changes.push({ tick, meter }); +} + +function addKeyChange(changes: Array<{ tick: number; key: string }>, tick: number, key: string): void { + const existing = changes.find((change) => change.tick === tick); + if (existing) existing.key = key; + else changes.push({ tick, key }); +} + +/** Compile semantic playback, expanding safe repeat paths and retaining explicit degradation warnings. */ +export function compilePlaybackTimeline(document: ScoreDocument, options: CompilePlaybackOptions = {}): PlaybackTimeline { + const ticksPerBeat = chooseTicksPerBeat(document, options.ticksPerBeat); + const tempoBpm = normalizeTempo(document.title.tempo, normalizeTempo(options.defaultTempoBpm, DEFAULT_TEMPO)); + const meter = parseMeter(document.title.meter, options.defaultMeter ?? DEFAULT_METER); + const key = normalizeKey(document.title.key, options.defaultKey ?? DEFAULT_KEY); + const voiceIds = document.voices.map((voice) => voice.id); + const events: PlaybackEvent[] = []; + const warnings: string[] = []; + const controlEvents: PlaybackControlEvent[] = []; + const tempoChanges: Array<{ tick: number; bpm: number }> = []; + const meterChanges: Array<{ tick: number; meter: { numerator: number; denominator: number } }> = []; + const keyChanges: Array<{ tick: number; key: string }> = []; + const voiceControls: Record = {}; + let durationTicks = 0; + let loopIterations = 1; + let currentKey = key; + + for (const [voiceIndex, voice] of document.voices.entries()) { + voiceControls[voice.id] = defaultVoiceControl(); + let voiceTick = 0; + const sequence = expandedMeasures(voice, warnings); + loopIterations = Math.max(loopIterations, ...sequence.map((occurrence) => occurrence.pass)); + for (const { measure, pass } of sequence) { + if (measure.tempoBpm !== null && measure.tempoBpm !== undefined) { + addTempoChange(tempoChanges, voiceTick, normalizeTempo(measure.tempoBpm, tempoBpm)); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "tempo", value: normalizeTempo(measure.tempoBpm, tempoBpm) }); + } + if (measure.meter) { + addMeterChange(meterChanges, voiceTick, measure.meter); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "meter", value: measure.meter }); + } + if (measure.key) { + currentKey = normalizeKey(measure.key, currentKey); + addKeyChange(keyChanges, voiceTick, currentKey); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "key", value: currentKey }); + } + const navigation = navigationForMeasure(measure); + if (navigation.dc || navigation.ds || navigation.fine) { + const value = navigation.dc ? "DC" : navigation.ds ? "DS" : "Fine"; + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "navigation", value }); + } + for (const event of measure.events) { + const controls = controlValues(event); + if (controls.tempo !== null) { + addTempoChange(tempoChanges, voiceTick, controls.tempo); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "tempo", value: controls.tempo }); + } + if (controls.meter !== null) { + addMeterChange(meterChanges, voiceTick, controls.meter); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "meter", value: controls.meter }); + } + if (controls.key !== null) { + currentKey = controls.key; + addKeyChange(keyChanges, voiceTick, controls.key); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "key", value: controls.key }); + } + if (!event.duration || event.duration.numerator <= 0) { + if (event.kind === "unknown" || event.kind === "text") warnings.push(`跳过无时值事件 ${event.id};工程仍保留原对象。`); + continue; + } + const duration = eventDurationTicks(event, ticksPerBeat); + if (duration <= 0) { + warnings.push(`事件 ${event.id} 的时值无法转换为正刻度,已跳过。`); + continue; + } + const occurrenceId = pass > 1 ? `${event.id}#${pass}` : event.id; + if (event.kind === "rest") { + events.push({ kind: "rest", eventId: occurrenceId, voiceId: voice.id, voiceIndex, startTick: voiceTick, durationTicks: duration, sourceEvent: event }); + } else if (event.kind === "note") { + const dynamics = dynamicsForEvent(event); + const pitches = [event.pitch, ...(event.chord?.slice(1) ?? [])].filter((pitch): pitch is NonNullable => pitch !== null && pitch !== undefined); + if (!pitches.length) { + warnings.push(`事件 ${event.id} 缺少可播放音高,已保留为不可发声对象。`); + } else { + const gateTicks = Math.max(1, Math.min(duration, Math.round(duration * (event.gateRatio ? ticksFor(event.gateRatio, ticksPerBeat) / ticksPerBeat : dynamics.gateRatio)))); + pitches.forEach((pitch, pitchIndex) => { + const midi = degreeToMidi(pitch.degree, pitch.accidental, pitch.octave, currentKey); + if (midi === null) warnings.push(`事件 ${event.id} 的和弦音 ${pitchIndex + 1} 缺少可播放音高。`); + else events.push({ kind: "note", eventId: pitchIndex === 0 ? occurrenceId : `${occurrenceId}:chord:${pitchIndex}`, voiceId: voice.id, voiceIndex, startTick: voiceTick, durationTicks: duration, gateTicks, midi: Math.min(127, Math.max(0, midi)), velocity: dynamics.velocity, sourceEvent: event }); + }); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "velocity", value: dynamics.velocity }); + controlEvents.push({ tick: voiceTick, voiceId: voice.id, type: "gate", value: gateTicks }); + } + } else if (event.kind !== "barline") { + warnings.push(`事件 ${event.id} 类型 ${event.kind} 不生成音频事件。`); + } + voiceTick += duration; + } + durationTicks = Math.max(durationTicks, voiceTick); + } + durationTicks = Math.max(durationTicks, voiceTick); + } + + events.sort((left, right) => left.startTick - right.startTick || left.voiceIndex - right.voiceIndex || left.eventId.localeCompare(right.eventId)); + controlEvents.sort((left, right) => left.tick - right.tick || left.voiceId.localeCompare(right.voiceId)); + return timelineObject(ticksPerBeat, tempoBpm, meter, key, voiceIds, events, durationTicks, warnings, voiceControls, controlEvents, tempoChanges, meterChanges, keyChanges, loopIterations); +} + +export function rationalToPlaybackTicks(value: Rational, ticksPerBeat = DEFAULT_TICKS_PER_BEAT): number { + return ticksFor(value, ticksPerBeat); +} + +export function playbackTicksToRational(ticks: number, ticksPerBeat = DEFAULT_TICKS_PER_BEAT): Rational { + return rational(Math.round(ticks), ticksPerBeat); +} + +export function midiForPitch(pitch: { degree: number | null; accidental: -1 | 0 | 1; octave: number | null }, key = DEFAULT_KEY): number | null { + return degreeToMidi(pitch.degree, pitch.accidental, pitch.octave, normalizeKey(key, DEFAULT_KEY)); +} + +export interface ScheduledPlaybackNote { + event: PlaybackNoteEvent; + whenSeconds: number; + durationSeconds: number; + control: PlaybackVoiceControl; +} + +export interface PlaybackSink { + start(): Promise | void; + scheduleNote(event: PlaybackNoteEvent, whenSeconds: number, durationSeconds: number, control: PlaybackVoiceControl): void; + stopAll(): void; +} + +/** Deterministic sink for timeline/controller tests and diagnostics. */ +export class RecordingPlaybackSink implements PlaybackSink { + readonly scheduled: ScheduledPlaybackNote[] = []; + started = false; + stopped = false; + + start(): void { + this.started = true; + this.stopped = false; + } + + scheduleNote(event: PlaybackNoteEvent, whenSeconds: number, durationSeconds: number, control: PlaybackVoiceControl): void { + if (!this.started || this.stopped) return; + this.scheduled.push({ event, whenSeconds, durationSeconds, control: { ...control } }); + } + + stopAll(): void { + this.scheduled.length = 0; + this.stopped = false; + } + + clear(): void { + this.scheduled.length = 0; + this.stopped = false; + } +} + +interface AudioNodeRecord { + oscillator: OscillatorNode; + gain: GainNode; +} + +/** Native Web Audio sink; AudioContext is created only after play() is called. */ +export class WebAudioPlaybackSink implements PlaybackSink { + private context: AudioContext | null = null; + private readonly active = new Set(); + + async start(): Promise { + if (!this.context) { + const scope = globalThis as typeof globalThis & { webkitAudioContext?: typeof AudioContext }; + const Constructor = globalThis.AudioContext ?? scope.webkitAudioContext; + if (!Constructor) throw new Error("当前浏览器不支持 Web Audio;播放已停止。"); + this.context = new Constructor(); + } + if (this.context.state === "suspended") await this.context.resume(); + } + + scheduleNote(event: PlaybackNoteEvent, whenSeconds: number, durationSeconds: number, control: PlaybackVoiceControl): void { + const context = this.context; + if (!context || control.muted || control.volume <= 0) return; + const now = context.currentTime + Math.max(0, whenSeconds); + const duration = Math.max(0.01, durationSeconds); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = control.waveform; + oscillator.frequency.setValueAtTime(440 * Math.pow(2, (event.midi - 69) / 12), now); + const peak = Math.min(1, Math.max(0, event.velocity / 127) * Math.max(0.05, Math.min(1, control.volume))); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), now + 0.005); + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + oscillator.connect(gain).connect(context.destination); + const record = { oscillator, gain }; + this.active.add(record); + oscillator.addEventListener("ended", () => { + this.active.delete(record); + oscillator.disconnect(); + gain.disconnect(); + }, { once: true }); + oscillator.start(now); + oscillator.stop(now + duration + 0.01); + } + + stopAll(): void { + for (const record of this.active) { + try { record.oscillator.stop(); } catch { /* already stopped */ } + record.oscillator.disconnect(); + record.gain.disconnect(); + } + this.active.clear(); + } + + async close(): Promise { + this.stopAll(); + if (this.context) await this.context.close(); + this.context = null; + } +} + +export interface PlaybackControllerOptions { + sink?: PlaybackSink; + now?: () => number; + setTimeout?: (handler: () => void, timeout: number) => ReturnType; + clearTimeout?: (handle: ReturnType) => void; + setInterval?: (handler: () => void, timeout: number) => ReturnType; + clearInterval?: (handle: ReturnType) => void; + onError?: (message: string) => void; +} + +type TimerSetter = (handler: () => void, timeout: number) => ReturnType; +type TimerClearer = (handle: ReturnType) => void; +type IntervalSetter = (handler: () => void, timeout: number) => ReturnType; +type IntervalClearer = (handle: ReturnType) => void; + +export class PlaybackController { + private readonly sink: PlaybackSink; + private readonly now: () => number; + private readonly setTimer: TimerSetter; + private readonly clearTimer: TimerClearer; + private readonly setPositionTimer: IntervalSetter; + private readonly clearPositionTimer: IntervalClearer; + private readonly onError?: (message: string) => void; + private timelineValue: PlaybackTimeline | null = null; + private stateValue: PlaybackState = "stopped"; + private currentTickValue = 0; + private startedAtMs = 0; + private startedAtTick = 0; + private endTimer: ReturnType | null = null; + private positionTimer: ReturnType | null = null; + private readonly stateListeners = new Set<(state: PlaybackState) => void>(); + private readonly positionListeners = new Set<(tick: number, seconds: number) => void>(); + private readonly controls = new Map(); + + constructor(timeline: PlaybackTimeline | null = null, options: PlaybackControllerOptions = {}) { + this.sink = options.sink ?? new WebAudioPlaybackSink(); + this.now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now()); + this.setTimer = options.setTimeout ?? ((handler, timeout) => globalThis.setTimeout(handler, timeout)); + this.clearTimer = options.clearTimeout ?? ((handle) => globalThis.clearTimeout(handle)); + this.setPositionTimer = options.setInterval ?? ((handler, timeout) => globalThis.setInterval(handler, timeout)); + this.clearPositionTimer = options.clearInterval ?? ((handle) => globalThis.clearInterval(handle)); + this.onError = options.onError; + if (timeline) this.load(timeline); + } + + get timeline(): PlaybackTimeline | null { return this.timelineValue; } + get state(): PlaybackState { return this.stateValue; } + get currentTick(): number { return this.currentTickValue; } + get currentSeconds(): number { return this.timelineValue?.secondsAtTick(this.currentTickValue) ?? 0; } + get voiceControls(): Record { + return Object.fromEntries([...this.controls.entries()].map(([id, control]) => [id, { ...control }])); + } + + load(timeline: PlaybackTimeline): void { + this.stop(); + this.timelineValue = timeline; + this.controls.clear(); + for (const voiceId of timeline.voiceIds) this.controls.set(voiceId, { ...(timeline.voiceControls[voiceId] ?? defaultVoiceControl()) }); + this.currentTickValue = 0; + this.emitPosition(); + } + + onStateChange(listener: (state: PlaybackState) => void): () => void { + this.stateListeners.add(listener); + return () => this.stateListeners.delete(listener); + } + + onPositionChange(listener: (tick: number, seconds: number) => void): () => void { + this.positionListeners.add(listener); + return () => this.positionListeners.delete(listener); + } + + async play(fromTick?: number): Promise { + const timeline = this.timelineValue; + if (!timeline || timeline.durationTicks <= 0) return; + if (fromTick !== undefined) this.currentTickValue = this.clampTick(fromTick); + if (this.stateValue === "playing") return; + if (this.currentTickValue >= timeline.durationTicks) this.currentTickValue = 0; + try { + await this.sink.start(); + } catch (error) { + this.setState("stopped"); + const message = error instanceof Error ? error.message : "音频启动失败;播放已停止。"; + this.onError?.(message); + throw error; + } + this.stateValue = "playing"; + this.emitState(); + this.scheduleFromCurrent(); + } + + async resume(): Promise { + return this.play(); + } + + pause(): void { + if (this.stateValue !== "playing") return; + this.updatePositionFromClock(); + this.cancelScheduling(); + this.sink.stopAll(); + this.setState("paused"); + } + + stop(): void { + this.cancelScheduling(); + this.sink.stopAll(); + this.currentTickValue = 0; + this.setState("stopped"); + this.emitPosition(); + } + + seek(tickOrBeat: number | Rational): void { + const timeline = this.timelineValue; + if (!timeline) return; + const tick = typeof tickOrBeat === "number" ? tickOrBeat : tickOrBeat.numerator / tickOrBeat.denominator * timeline.ticksPerBeat; + this.currentTickValue = this.clampTick(tick); + if (this.stateValue === "playing") { + this.cancelScheduling(); + this.sink.stopAll(); + this.scheduleFromCurrent(); + } else { + this.emitPosition(); + } + } + + seekBeat(beat: Rational): void { + this.seek(beat); + } + + setVoiceControl(voiceId: StableId, patch: Partial): void { + const current = this.controls.get(voiceId); + if (!current) return; + const next: PlaybackVoiceControl = { + ...current, + ...patch, + volume: Number.isFinite(patch.volume) ? Math.max(0, Math.min(1, patch.volume as number)) : current.volume + }; + this.controls.set(voiceId, next); + if (this.stateValue === "playing") { + this.updatePositionFromClock(); + this.cancelScheduling(); + this.sink.stopAll(); + this.scheduleFromCurrent(); + } + } + + destroy(): void { + this.stop(); + const sink = this.sink as Partial; + if (typeof sink.close === "function") void sink.close(); + this.stateListeners.clear(); + this.positionListeners.clear(); + } + + private clampTick(tick: number): number { + const duration = this.timelineValue?.durationTicks ?? 0; + return Math.max(0, Math.min(duration, Math.round(tick))); + } + + private updatePositionFromClock(): void { + const timeline = this.timelineValue; + if (!timeline || this.stateValue !== "playing") return; + const elapsedSeconds = Math.max(0, this.now() - this.startedAtMs) / 1000; + this.currentTickValue = this.clampTick(this.startedAtTick + timeline.ticksAtSeconds(elapsedSeconds)); + this.emitPosition(); + } + + private scheduleFromCurrent(): void { + const timeline = this.timelineValue; + if (!timeline) return; + this.startedAtMs = this.now(); + this.startedAtTick = this.currentTickValue; + const soloActive = [...this.controls.values()].some((control) => control.solo); + for (const event of timeline.events) { + if (event.kind !== "note" || event.startTick < this.currentTickValue) continue; + const control = this.controls.get(event.voiceId) ?? defaultVoiceControl(); + if (control.muted || (soloActive && !control.solo)) continue; + const whenSeconds = Math.max(0, timeline.secondsAtTick(event.startTick) - timeline.secondsAtTick(this.currentTickValue)); + const durationSeconds = timeline.secondsAtTick(event.startTick + event.gateTicks) - timeline.secondsAtTick(event.startTick); + this.sink.scheduleNote(event, whenSeconds, Math.max(0.01, durationSeconds), control); + } + const remainingSeconds = Math.max(0, timeline.secondsAtTick(timeline.durationTicks) - timeline.secondsAtTick(this.currentTickValue)); + this.endTimer = this.setTimer(() => this.finishPlayback(), Math.max(1, Math.ceil(remainingSeconds * 1000))) ?? null; + this.positionTimer = this.setPositionTimer(() => this.updatePositionFromClock(), 50) ?? null; + this.emitPosition(); + } + + private finishPlayback(): void { + const timeline = this.timelineValue; + if (!timeline) return; + this.currentTickValue = timeline.durationTicks; + this.cancelScheduling(); + this.sink.stopAll(); + this.setState("stopped"); + this.emitPosition(); + } + + private cancelScheduling(): void { + if (this.endTimer !== null) this.clearTimer(this.endTimer); + if (this.positionTimer !== null) this.clearPositionTimer(this.positionTimer); + this.endTimer = null; + this.positionTimer = null; + } + + private setState(state: PlaybackState): void { + if (this.stateValue === state) return; + this.stateValue = state; + this.emitState(); + } + + private emitState(): void { + for (const listener of this.stateListeners) listener(this.stateValue); + } + + private emitPosition(): void { + for (const listener of this.positionListeners) listener(this.currentTickValue, this.currentSeconds); + } +} + +export function createDefaultPlaybackController(timeline: PlaybackTimeline | null = null): PlaybackController { + return new PlaybackController(timeline); +} diff --git a/test/m0.test.mjs b/test/m0.test.mjs new file mode 100644 index 0000000..ccf992f --- /dev/null +++ b/test/m0.test.mjs @@ -0,0 +1,20 @@ +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import assert from "node:assert/strict"; + +const run = (command, args) => execFileSync(command, args, { encoding: "utf8", stdio: "pipe" }); + +test("M0 baseline check passes", () => { + const output = run(process.execPath, ["scripts/m0-check.mjs"]); + const result = JSON.parse(output); + assert.equal(result.ok, true); + assert.equal(result.samplePairs, 33); +}); + +test("M0 build emits a browser entry point", async () => { + const source = await readFile("dist/main.js", "utf8"); + const page = await readFile("dist/index.html", "utf8"); + assert.match(source, /mountEditor/); + assert.match(page, /main\.js/); +}); diff --git a/test/m1.test.mjs b/test/m1.test.mjs new file mode 100644 index 0000000..e6f90e3 --- /dev/null +++ b/test/m1.test.mjs @@ -0,0 +1,85 @@ +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { encodeSource, hasExactRoundTrip, parseJpwabc, serializeBytes, serializeText } from "../dist/domain/jpwabc.js"; + +const readRaw = (path) => readFile(path); + +test("M1 decodes UTF-16LE/BOM and preserves the 康定情歌 source", async () => { + const result = parseJpwabc(await readRaw("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc")); + assert.equal(result.source.encoding, "utf-16le"); + assert.equal(result.source.bom, "utf-16le"); + assert.equal(result.header.formatVersion, "1.0"); + assert.equal(result.header.productVersion, "7.30"); + assert.deepEqual(result.sections.map((section) => section.name), ["", ".Options", ".Fonts", ".Title", ".Voice", ".Words", ".Attachments", ".Page"]); + assert.ok(result.options.document.length >= 0); + assert.ok(result.fonts.length > 0); + assert.ok(result.titles.some((entry) => entry.field === "Title" && entry.value === "康定情歌")); + assert.ok(result.voices.some((line) => line.tokens.some((token) => token.kind === "note"))); + assert.ok(result.voices.some((line) => line.tokens.some((token) => token.kind === "barline"))); + assert.ok(result.words.length >= 4); + assert.ok(result.attachments.length > 0); + assert.equal(hasExactRoundTrip(result), true); + assert.equal(serializeText(result), result.source.text); + assert.deepEqual([...serializeBytes(result)], [...result.source.bytes]); + const reparsed = parseJpwabc(serializeBytes(result)); + assert.equal(serializeText(reparsed), serializeText(result)); + assert.deepEqual([...serializeBytes(reparsed)], [...result.source.bytes]); +}); + +test("M1 detects UTF-16BE and UTF-8 BOM variants", () => { + const source = ".Title\r\nTitle = 编码探针\r\n"; + const bigEndian = parseJpwabc(encodeSource(source, "utf-16be", "utf-16be")); + const utf8 = parseJpwabc(encodeSource(source, "utf-8", "utf-8")); + assert.equal(bigEndian.source.encoding, "utf-16be"); + assert.equal(bigEndian.source.bom, "utf-16be"); + assert.equal(utf8.source.encoding, "utf-8"); + assert.equal(utf8.source.bom, "utf-8"); + assert.equal(hasExactRoundTrip(bigEndian), true); + assert.equal(hasExactRoundTrip(utf8), true); +}); + +test("M1 retains unknown sections, unknown options and source ranges", () => { + const source = [ + "// ******** JPW-ABC File Ver 9.9 ********", + "", + ".Options", + "KnownLater = {opaque:value}", + "", + ".MysteryBlock", + "Payload {vendor:private} ", + "", + ".Voice", + "1_ {vendor:private} | 0__", + "" + ].join("\r\n"); + const result = parseJpwabc(encodeSource(source, "utf-16le", "utf-16le")); + assert.equal(result.source.encoding, "utf-16le"); + assert.equal(result.sections.find((section) => section.name === ".MysteryBlock")?.known, false); + assert.ok(result.options.unknownDocument.some((entry) => entry.key === "KnownLater")); + assert.ok(result.unknownBlocks.some((block) => block.reason === "unknown-section")); + assert.ok(result.unknownTokens.some((token) => token.head === "vendor")); + assert.ok(result.diagnostics.some((item) => item.code === "UNKNOWN_SECTION")); + assert.equal(result.compatibility.originalExport, "blocked"); + assert.equal(hasExactRoundTrip(result), true); + assert.ok(result.sections.every((section) => section.range.byteEnd >= section.range.byteStart)); +}); + +test("M1 reports malformed structures without dropping their bytes", () => { + const source = ".Voice\n1_ {unclosed\n"; + const result = parseJpwabc(encodeSource(source, "utf-8", "none")); + assert.ok(result.diagnostics.some((item) => item.code === "UNCLOSED_BRACE")); + assert.equal(result.compatibility.projectSave, "blocked"); + assert.equal(result.compatibility.originalExport, "blocked"); + assert.equal(hasExactRoundTrip(result), true); +}); + +test("M1 classification report covers all 33 fixtures", async () => { + const report = JSON.parse(await readFile("acceptance/m1/sample-classification.json", "utf8")); + assert.equal(report.sampleCountExpected, 33); + assert.equal(report.sampleCountActual, 33); + assert.equal(report.allSourceBytesPreserved, true); + assert.equal(report.failures.length, 0); + assert.equal(report.samples.length, 33); + assert.ok(report.samples.every((sample) => sample.status && sample.sourcePreserved === true)); +}); diff --git a/test/m2.test.mjs b/test/m2.test.mjs new file mode 100644 index 0000000..dbc64de --- /dev/null +++ b/test/m2.test.mjs @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + createEmptyDocument, + createNoteEvent, + createRestEvent, + rational, + modelEquivalent, + modelFromParse, + validateScore +} from "../dist/domain/score-model.js"; +import { CommandRejectedError, CommandStore } from "../dist/domain/command-store.js"; +import { decodeProject, encodeProject, ProjectCodecError, projectEquivalentAfterReopen } from "../dist/domain/project-codec.js"; +import { parseJpwabc } from "../dist/domain/jpwabc.js"; + +function makeDocument() { + const note1 = createNoteEvent("event-note-1", 1, rational(1)); + const note2 = createNoteEvent("event-note-2", 2, rational(1)); + const note3 = createNoteEvent("event-note-3", 3, rational(1)); + const rest = createRestEvent("event-rest-1", rational(1)); + const text = { id: "event-text-1", kind: "text", pitch: null, duration: null, text: "表情", source: null }; + const measure = { id: "measure-1", ordinal: 0, beats: rational(4), events: [note1, note2, note3, rest, text] }; + const voice = { id: "voice-1", name: "主旋律", sectionId: null, visible: true, measures: [measure] }; + const document = createEmptyDocument("document-test"); + document.title.title = "测试曲"; + document.voices.push(voice); + document.attachments.push({ + id: "attachment-1", + kind: "Text", + raw: "Text@event-text-1 = 表情", + anchor: { raw: "event-text-1", startId: "event-text-1", endId: null }, + status: "attached", + sourceRange: null + }); + return { document, note1, text, voice, measure }; +} + +test("M2 validates durations and rejects invalid edits atomically", () => { + const { document, voice, measure } = makeDocument(); + const store = new CommandStore(document); + const before = store.snapshot(); + assert.equal(store.dirty, false); + assert.throws(() => store.execute({ + type: "insert-event", + voiceId: voice.id, + measureId: measure.id, + event: createNoteEvent("event-invalid", 5, rational(2)) + }), (error) => error instanceof CommandRejectedError && error.diagnostics.some((item) => item.code === "MEASURE_DURATION_MISMATCH")); + assert.equal(modelEquivalent(store.snapshot(), before), true); + assert.equal(store.dirty, false); +}); + +test("M2 insertion, modification, deletion, undo and redo preserve stable IDs", () => { + const { document, note1, voice, measure } = makeDocument(); + const store = new CommandStore(document); + store.execute({ + type: "insert-event", + voiceId: voice.id, + measureId: measure.id, + index: 0, + event: { id: "event-text-2", kind: "text", pitch: null, duration: null, text: "新文字", source: null } + }); + store.execute({ type: "update-event", eventId: note1.id, patch: { pitch: { degree: 7, accidental: 1, octave: 1 } } }); + store.execute({ type: "delete-event", eventId: "event-text-2" }); + assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), false); + assert.equal(store.snapshot().voices[0].measures[0].events.find((event) => event.id === note1.id)?.pitch?.degree, 7); + store.undo(); + assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), true); + assert.equal(store.snapshot().voices[0].measures[0].events.find((event) => event.id === note1.id)?.id, note1.id); + store.redo(); + assert.equal(store.snapshot().voices[0].measures[0].events.some((event) => event.id === "event-text-2"), false); + assert.equal(store.canUndo, true); + assert.equal(store.canRedo, false); +}); + +test("M2 deleting an anchored non-timed object retains an orphan diagnostic", () => { + const { document, text } = makeDocument(); + const store = new CommandStore(document); + store.execute({ type: "delete-event", eventId: text.id }); + const attachment = store.snapshot().attachments[0]; + assert.equal(attachment.id, "attachment-1"); + assert.equal(attachment.anchor.startId, null); + assert.equal(attachment.status, "orphaned"); + assert.ok(store.model.validate().diagnostics.some((item) => item.code === "ORPHANED_ANCHOR")); +}); + +test("M2 ProjectCodec saves and reopens the current model without undo history", () => { + const { document, note1, voice, measure } = makeDocument(); + const store = new CommandStore(document); + store.execute({ type: "set-title", field: "title", value: "编辑后曲名" }); + store.execute({ type: "update-event", eventId: note1.id, patch: { text: "高音" } }); + store.markSaved(); + assert.equal(store.dirty, false); + const text = encodeProject(store.snapshot()); + const reopened = decodeProject(text); + assert.equal(projectEquivalentAfterReopen(store.snapshot(), reopened), true); + assert.equal(reopened.documentId, "document-test"); + assert.equal(reopened.title.title, "编辑后曲名"); + assert.equal(reopened.voices[0].measures[0].events.find((event) => event.id === note1.id)?.text, "高音"); + assert.doesNotMatch(text, /undoStack|redoStack|LayoutSnapshot|diagnostics/); + assert.equal(encodeProject(reopened), text); + assert.equal(store.canUndo, true); + assert.equal(store.canRedo, false); + assert.equal(voice.id, "voice-1"); + assert.equal(measure.id, "measure-1"); +}); + +test("M2 ProjectCodec rejects unknown schema fields and invalid nested types", () => { + const { document } = makeDocument(); + const project = JSON.parse(encodeProject(document)); + project.futureField = true; + assert.throws(() => decodeProject(JSON.stringify(project)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "UNKNOWN_FIELD")); + delete project.futureField; + project.schemaVersion = 99; + assert.throws(() => decodeProject(JSON.stringify(project)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "UNSUPPORTED_SCHEMA")); + const invalidDuration = JSON.parse(encodeProject(document)); + invalidDuration.score.voices[0].measures[0].events[0].duration = "not-a-rational"; + assert.throws(() => decodeProject(JSON.stringify(invalidDuration)), (error) => error instanceof ProjectCodecError && error.issues.some((issue) => issue.code === "INVALID_TYPE")); +}); + +test("M2 converts the parsed JPW-ABC AST into stable editable model IDs", async () => { + const bytes = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc"); + const parsed = parseJpwabc(bytes); + const first = modelFromParse(parsed, { fileName: "康定情歌.jpwabc" }); + const second = modelFromParse(parseJpwabc(bytes), { fileName: "康定情歌.jpwabc" }); + const validation = validateScore(first); + assert.equal(validation.valid, true); + assert.equal(first.documentId, second.documentId); + assert.deepEqual(first.voices.map((voice) => voice.id), second.voices.map((voice) => voice.id)); + assert.ok(first.unknownBlocks.length > 0 || first.attachments.some((attachment) => attachment.status !== "attached")); + assert.ok(first.source?.rawBase64); + assert.equal(projectEquivalentAfterReopen(first, decodeProject(encodeProject(first))), true); +}); diff --git a/test/m3.test.mjs b/test/m3.test.mjs new file mode 100644 index 0000000..2991b41 --- /dev/null +++ b/test/m3.test.mjs @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { parseJpwabc } from "../dist/domain/jpwabc.js"; +import { modelFromParse, createEmptyDocument, rational, createNoteEvent } from "../dist/domain/score-model.js"; +import { DeterministicFontMeasurementProvider } from "../dist/layout/font-measure.js"; +import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../dist/layout/layout-engine.js"; +import { probeSvg, renderSnapshotSvg } from "../dist/layout/svg-renderer.js"; + +async function kangdingModel() { + const bytes = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc"); + return modelFromParse(parseJpwabc(bytes), { fileName: "康定情歌.jpwabc" }); +} + +test("M3 waits for fonts, produces fixed A4 geometry and the 16-measure/6-line baseline", async () => { + const provider = new DeterministicFontMeasurementProvider(); + const snapshot = await layoutDocument(await kangdingModel(), DEFAULT_PAPER_CONFIG, provider); + const summary = layoutSummary(snapshot); + assert.deepEqual(snapshot.pageSizeMm, { width: 210, height: 297 }); + assert.deepEqual(snapshot.contentRectMm, { x: 20, y: 20, width: 170, height: 257 }); + assert.equal(summary.pageCount, 1); + assert.equal(summary.lineCount, 6); + assert.equal(summary.measureCount, 16); + assert.ok(snapshot.fontMeasurements.length > 0); + assert.ok(snapshot.fontMeasurements.every((measurement) => measurement.loaded === true)); + assert.equal(snapshot.pages[0].widthMm, 210); + assert.equal(snapshot.pages[0].heightMm, 297); +}); + +test("M3 layout is deterministic for identical model, config and measurement provider", async () => { + const model = await kangdingModel(); + const first = await layoutDocument(model, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider()); + const second = await layoutDocument(model, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider()); + assert.equal(first.deterministicKey, second.deterministicKey); + assert.deepEqual(first.pages, second.pages); + assert.deepEqual(first.warnings, second.warnings); +}); + +test("M3 reports an unbreakable over-wide measure instead of clipping it", async () => { + const document = createEmptyDocument("overflow-document"); + const event = createNoteEvent("wide-event", 1, rational(1)); + event.source = { raw: "X".repeat(800), range: null }; + document.voices.push({ id: "wide-voice", name: "宽小节", sectionId: null, visible: true, measures: [{ id: "wide-measure", ordinal: 0, beats: rational(1), events: [event] }] }); + const snapshot = await layoutDocument(document, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider()); + const warning = snapshot.warnings.find((item) => item.code === "MEASURE_OVERFLOW"); + assert.ok(warning); + assert.equal(warning.severity, "error"); + assert.equal(snapshot.measures["wide-measure"].overflow, true); +}); + +test("M3 SVG renderer preserves A4 physical units, semantic IDs and layer separation", async () => { + const snapshot = await layoutDocument(await kangdingModel(), DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider()); + const [svg] = renderSnapshotSvg(snapshot); + const probe = probeSvg(svg); + assert.equal(probe.width, "210mm"); + assert.equal(probe.height, "297mm"); + assert.equal(probe.viewBox, "0 0 21000 29700"); + assert.equal(probe.pageNumber, 1); + assert.equal(probe.hasScoreLayer, true); + assert.equal(probe.hasAssistiveLayer, false); + assert.ok(probe.dataIds.length >= Object.keys(snapshot.symbols).length); + const accessibleProbe = probeSvg(renderSnapshotSvg(snapshot, true)[0]); + assert.equal(accessibleProbe.hasAssistiveLayer, true); +}); + + test("M3 overflow and orphan diagnostics are visible in the snapshot, not hidden by renderer", async () => { + const snapshot = await layoutDocument(await kangdingModel(), DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider()); + assert.ok(snapshot.warnings.some((warning) => warning.code === "ORPHANED_ATTACHMENT")); + assert.ok(snapshot.pages.every((page) => page.widthMm === 210 && page.heightMm === 297)); +}); diff --git a/test/m5.test.mjs b/test/m5.test.mjs new file mode 100644 index 0000000..7207364 --- /dev/null +++ b/test/m5.test.mjs @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createEmptyDocument, + createNoteEvent, + createRestEvent, + modelFromParse, + rational +} from "../dist/domain/score-model.js"; +import { parseJpwabc } from "../dist/domain/jpwabc.js"; +import { CompatibilityExportBlockedError, serializeJpwabc } from "../dist/domain/jpwabc-writer.js"; +import { + compilePlaybackTimeline, + PlaybackController, + RecordingPlaybackSink +} from "../dist/playback/playback.js"; +import { buildPrintableHtml, pixelsForMillimeters } from "../dist/output/output.js"; +import { DEFAULT_PAPER_CONFIG } from "../dist/layout/layout-engine.js"; + +function eightVoiceDocument() { + const document = createEmptyDocument("m5-eight-voice"); + document.title.title = "M5 fixture"; + document.title.key = "C"; + document.title.meter = "4/4"; + document.title.tempo = 120; + for (let voiceIndex = 0; voiceIndex < 8; voiceIndex += 1) { + const events = [ + createNoteEvent(`v${voiceIndex}-1`, 1, rational(1)), + createRestEvent(`v${voiceIndex}-r`, rational(1, 2)), + createNoteEvent(`v${voiceIndex}-2`, 3, rational(1, 2)), + createNoteEvent(`v${voiceIndex}-3`, 5, rational(2)) + ]; + document.voices.push({ + id: `voice-${voiceIndex + 1}`, + name: `Voice ${voiceIndex + 1}`, + sectionId: null, + visible: true, + measures: [{ id: `measure-${voiceIndex + 1}`, ordinal: 0, beats: rational(4), events }] + }); + } + return document; +} + +test("M5 PlaybackTimeline compiles exact durations and eight simultaneous voices", () => { + const document = eightVoiceDocument(); + const timeline = compilePlaybackTimeline(document); + assert.equal(timeline.voiceIds.length, 8); + assert.equal(timeline.tempoBpm, 120); + assert.equal(timeline.meter.numerator, 4); + assert.equal(timeline.events.filter((event) => event.kind === "note" && event.startTick === 0).length, 8); + assert.equal(timeline.events.find((event) => event.eventId === "v0-r").startTick, 960); + assert.equal(timeline.events.find((event) => event.eventId === "v0-2").durationTicks, 480); + assert.equal(timeline.durationTicks, 3840); + assert.ok(Math.abs(timeline.secondsAtTick(3840) - 2) < 1e-12); +}); + +test("M5 PlaybackController schedules, pauses, seeks and applies voice controls", async () => { + const timeline = compilePlaybackTimeline(eightVoiceDocument()); + const sink = new RecordingPlaybackSink(); + const controller = new PlaybackController(timeline, { sink }); + await controller.play(); + assert.equal(controller.state, "playing"); + assert.equal(sink.scheduled.length, 24); + controller.setVoiceControl("voice-1", { muted: true }); + assert.equal(controller.state, "playing"); + assert.ok(sink.scheduled.every((entry) => entry.event.voiceId !== "voice-1")); + controller.pause(); + assert.equal(controller.state, "paused"); + controller.seek(0); + assert.equal(controller.currentTick, 0); + controller.stop(); + assert.equal(controller.state, "stopped"); + assert.equal(controller.currentTick, 0); + controller.destroy(); +}); + +test("M5 core JPW-ABC writer emits UTF-16LE and blocks unsafe content", () => { + const document = createEmptyDocument("writer-safe"); + document.title.title = "核心写出"; + document.title.key = "C"; + document.title.meter = "4/4"; + const event = createNoteEvent("writer-note", 1, rational(2)); + event.pitch.octave = 1; + document.voices.push({ id: "writer-voice", name: "主旋律", sectionId: null, visible: true, measures: [{ id: "writer-measure", ordinal: 0, beats: rational(2), events: [event] }] }); + const result = serializeJpwabc(document); + assert.deepEqual([...result.bytes.slice(0, 2)], [0xff, 0xfe]); + assert.match(result.text, /\.Voice/); + assert.match(result.text, /1g-/); + const reopened = parseJpwabc(result.bytes); + assert.equal(reopened.compatibility.originalExport, "safe"); + assert.equal(reopened.voices.length, 1); + const reopenedModel = modelFromParse(reopened); + assert.equal(reopenedModel.voices[0].measures[0].events[0].duration.numerator / reopenedModel.voices[0].measures[0].events[0].duration.denominator, 2); + assert.equal(reopenedModel.voices[0].measures[0].events[0].pitch.octave, 1); + + document.unknownBlocks.push({ id: "unknown-1", reason: "unknown-section", rawText: ".Private", context: ".Private", sourceRange: null }); + assert.throws(() => serializeJpwabc(document), CompatibilityExportBlockedError); +}); + +test("M5 output geometry is fixed A4 and printable HTML omits assistive layers", () => { + const dimensions = pixelsForMillimeters(210, 297, 300); + assert.deepEqual(dimensions, { width: 2480, height: 3508, dpi: 300 }); + const snapshot = { + version: 1, + sourceDocumentId: "doc", + documentTitle: "test", + pageSizeMm: { width: 210, height: 297 }, + contentRectMm: { x: 20, y: 20, width: 170, height: 257 }, + config: DEFAULT_PAPER_CONFIG, + pages: [{ pageNumber: 1, widthMm: 210, heightMm: 297, contentRectMm: { x: 20, y: 20, width: 170, height: 257 }, titleRectMm: { x: 20, y: 20, width: 170, height: 15 }, lines: [], measures: [], symbols: [], attachments: [], pageObjects: [] }], + measures: {}, symbols: {}, attachments: {}, warnings: [], fontMeasurements: [], deterministicKey: "m5" + }; + const html = buildPrintableHtml(snapshot); + assert.match(html, /size:A4 portrait/); + assert.doesNotMatch(html, /data-layer="assistive"/); +}); diff --git a/test/m6.test.mjs b/test/m6.test.mjs new file mode 100644 index 0000000..d0b42a8 --- /dev/null +++ b/test/m6.test.mjs @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseJpwabc } from "../dist/domain/jpwabc.js"; +import { modelFromParse, createEmptyDocument, createNoteEvent, rational } from "../dist/domain/score-model.js"; +import { encodeProject, decodeProject } from "../dist/domain/project-codec.js"; +import { applyEditCommand, CommandStore } from "../dist/domain/command-store.js"; +import { DEFAULT_PAPER_CONFIG, layoutDocument } from "../dist/layout/layout-engine.js"; +import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js"; +import { renderSnapshotSvg } from "../dist/layout/svg-renderer.js"; + +function parseFixture() { + return modelFromParse(parseJpwabc(new TextEncoder().encode(`JPW-ABC File Ver 7.30\n.Voice\n1 {(3} [13] {DunYin} 2 3 |: 4 Hide 5 :|\n.Words\nW1@1,1(True,Lyric,1.00,$FF000000,False):\n{(女)[跑]}马溜/溜的山/上\nW2@1,1(True,Lyric,1.00,$FF000000,False):\n第二段歌词\n.Attachments\nText@2,1 = hello\nArc@1,1 = curve\n.Page\nMemo@1,20,30 = hello\n`))); +} + +test("M6 parses tuplets, chords, decorations, hidden events and complex lyrics", () => { + const document = parseFixture(); + const events = document.voices[0].measures.flatMap((measure) => measure.events); + assert.equal(events.filter((event) => event.tuplet?.ratio === 3).length, 3); + assert.deepEqual(events[1].chord?.map((pitch) => pitch.degree), [1, 3]); + assert.equal(events[1].decorations?.[0].kind, "DunYin"); + assert.equal(events.find((event) => event.hidden)?.text, "Hide"); + const lyrics = document.attachments.filter((attachment) => attachment.kind === "Lyric"); + assert.equal(lyrics.length, 2); + assert.equal(lyrics[0].status, "attached"); + assert.equal(lyrics[0].lyric.prefix, "{(女)[跑]}"); + assert.deepEqual(lyrics[0].lyric.syllables, ["马", "溜", "溜", "的", "山", "上"]); + assert.equal(lyrics[0].lyric.syllableEventIds?.length, 6); + assert.equal(new Set(lyrics[0].lyric.syllableEventIds?.filter(Boolean)).size, 6); + assert.equal(document.attachments.find((attachment) => attachment.kind === "Text").status, "attached"); + assert.deepEqual(document.pageObjects[0].position, { x: 20, y: 30 }); + assert.equal(document.voices[0].measures[0].barlineAfter, "|:"); + assert.equal(document.voices[0].measures[1].barlineAfter, ":|"); +}); + +test("M6 editor commands update chords, decorations, tuplets and barline/repeat fields", () => { + const document = parseFixture(); + const eventId = document.voices[0].measures[0].events[1].id; + const withEventFields = applyEditCommand(document, { + type: "update-event", + eventId, + patch: { + chord: [{ degree: 1, accidental: 0, octave: null }, { degree: 5, accidental: 0, octave: null }], + decorations: [{ id: "m6-decoration", kind: "staccato", raw: "staccato", playback: "visual", sourceRange: null }], + tuplet: { id: "m6-tuplet", ratio: 3, ordinal: 1, raw: "3:1" } + } + }); + const withMeasureFields = applyEditCommand(withEventFields, { + type: "update-measure", + measureId: withEventFields.voices[0].measures[0].id, + patch: { barlineAfter: ":|", repeatStart: true, repeatEnd: true, ending: 1 } + }); + const event = withMeasureFields.voices[0].measures[0].events[1]; + assert.deepEqual(event.chord?.map((pitch) => pitch.degree), [1, 5]); + assert.equal(event.decorations?.[0].kind, "staccato"); + assert.equal(event.tuplet?.ratio, 3); + assert.equal(withMeasureFields.voices[0].measures[0].barlineAfter, ":|"); + assert.equal(withMeasureFields.voices[0].measures[0].repeatStart, true); + assert.equal(withMeasureFields.voices[0].measures[0].ending, 1); +}); + +test("M6 project codec preserves complex semantic fields and source attachments", () => { + const document = parseFixture(); + const reopened = decodeProject(encodeProject(document)); + const event = reopened.voices[0].measures[0].events[1]; + assert.equal(event.chord?.length, 2); + assert.equal(event.decorations?.[0].raw, "{DunYin}"); + assert.equal(event.tuplet?.ratio, 3); + assert.equal(reopened.attachments.filter((attachment) => attachment.lyric).length, 2); + assert.deepEqual(reopened.pageObjects[0].position, { x: 20, y: 30 }); +}); + +test("M6 lyric editing preserves one-to-many syllables, punctuation, undo/redo and anchors", () => { + const store = new CommandStore(parseFixture()); + const lyric = store.snapshot().attachments.find((attachment) => attachment.kind === "Lyric"); + assert.ok(lyric); + const raw = `${lyric.raw.split("\n", 1)[0]}\n一音多字-A,B。C\n`; + store.execute({ type: "set-attachment-raw", attachmentId: lyric.id, raw }); + const edited = store.snapshot().attachments.find((attachment) => attachment.id === lyric.id); + assert.deepEqual(edited?.lyric?.syllables, ["一", "音", "多", "字", "A", "B", "C"]); + assert.equal(edited?.lyric?.syllableEventIds?.filter(Boolean).length, 7); + store.undo(); + assert.notEqual(store.snapshot().attachments.find((attachment) => attachment.id === lyric.id)?.raw, raw); + store.redo(); + const reopened = decodeProject(encodeProject(store.snapshot())); + assert.equal(reopened.attachments.find((attachment) => attachment.id === lyric.id)?.raw, raw); + assert.deepEqual(reopened.attachments.find((attachment) => attachment.id === lyric.id)?.lyric?.syllables, edited?.lyric?.syllables); +}); + +test("M6 visual versus real meter/key edits remain distinct in the saved timeline", () => { + const document = parseFixture(); + const withMusicControls = applyEditCommand(document, { type: "set-title", field: "meter", value: "3/4" }); + const withVisualText = applyEditCommand(withMusicControls, { type: "insert-event", voiceId: withMusicControls.voices[0].id, measureId: withMusicControls.voices[0].measures[0].id, event: { id: "visual-meter", kind: "text", pitch: null, duration: null, text: "拍号文字 4/4", source: null } }); + const reopened = decodeProject(encodeProject(withVisualText)); + assert.equal(reopened.title.meter, "3/4"); + assert.equal(reopened.voices[0].measures[0].events.find((event) => event.id === "visual-meter")?.duration, null); +}); + +test("M6 layout renders decoration, chord, complex lyrics, page object and complex barlines", async () => { + const document = parseFixture(); + const snapshot = await layoutDocument(document, DEFAULT_PAPER_CONFIG, createDefaultFontMeasurementProvider()); + const svg = renderSnapshotSvg(snapshot)[0]; + assert.match(svg, /data-layer="decoration"/); + assert.match(svg, /data-layer="chord"/); + assert.match(svg, /data-kind="attachment"/); + assert.match(svg, /data-layer="lyric-syllable"/); + assert.match(svg, /data-layer="attachment-curve"/); + assert.match(svg, /data-layer="barline"/); + assert.match(svg, /data-layer="page-objects"/); + assert.ok(Object.values(snapshot.attachments).some((attachment) => attachment.visible)); +}); + +test("M6 layout segments an anchored attachment across pages without dropping stable ID", async () => { + const document = createEmptyDocument("m6-cross-page"); + const events = Array.from({ length: 80 }, (_, index) => createNoteEvent(`m6-event-${index}`, (index % 7) + 1, rational(1))); + document.voices.push({ id: "m6-voice", name: "主旋律", sectionId: null, visible: true, measures: events.map((event, index) => ({ id: `m6-measure-${index}`, ordinal: index, beats: rational(1), events: [event] })) }); + const start = events[0]; + const end = events.at(-1); + document.attachments.push({ id: "m6-range", kind: "Arc", raw: "Arc@range", anchor: { raw: "Arc@range", startId: start.id, endId: end.id }, status: "attached", sourceRange: null, visible: true }); + const snapshot = await layoutDocument(document, { ...DEFAULT_PAPER_CONFIG, lineHeightMm: 30 }, createDefaultFontMeasurementProvider()); + const segments = snapshot.pages.flatMap((page) => page.attachments).filter((attachment) => attachment.id === "m6-range"); + assert.ok(snapshot.pages.length > 1); + assert.ok(segments.length > 1); + assert.ok(segments.every((segment) => segment.continued)); + assert.equal(snapshot.attachments["m6-range"].id, "m6-range"); +}); diff --git a/test/m7.test.mjs b/test/m7.test.mjs new file mode 100644 index 0000000..3ca5d49 --- /dev/null +++ b/test/m7.test.mjs @@ -0,0 +1,118 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { CommandStore } from "../dist/domain/command-store.js"; +import { decodeProject, encodeProject } from "../dist/domain/project-codec.js"; +import { createEmptyDocument, createNoteEvent, rational, modelEquivalent, validateScore } from "../dist/domain/score-model.js"; +import { DEFAULT_PAPER_CONFIG, layoutDocument } from "../dist/layout/layout-engine.js"; +import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js"; +import { preflightJpwabcExport } from "../dist/domain/jpwabc-writer.js"; + +function measure(id, ordinal, degree) { + return { id, ordinal, beats: rational(1), events: [createNoteEvent(`${id}-event`, degree, rational(1))] }; +} + +function baseDocument() { + const document = createEmptyDocument("m7-document"); + document.voices.push({ id: "voice-a", name: "主旋律", sectionId: null, visible: true, alignment: "aligned", measures: [measure("measure-a-1", 0, 1), measure("measure-a-2", 1, 2), measure("measure-a-3", 2, 3)] }); + document.voices.push({ id: "voice-b", name: "和声", sectionId: "section-1", visible: true, alignment: "overlay", measures: [measure("measure-b-1", 0, 3), measure("measure-b-2", 1, 5)] }); + return document; +} + +test("M7 manual layout and voice commands are editable and survive project reopen", () => { + const store = new CommandStore(baseDocument()); + store.execute({ type: "set-manual-layout", patch: { + lockedMeasureIds: ["measure-a-1"], + forcedBreakAfterMeasureIds: ["measure-a-1"], + measureSpacing: { "measure-a-1": 8 }, + measurePositions: { "measure-a-1": { x: 3, y: 2 } }, + objectPositions: { "measure-a-1-event": { x: 1.5, y: -0.5 } }, + voiceOrder: ["voice-b", "voice-a"], + voiceSpacing: { "voice-b": 5 }, + voiceAlignment: { "voice-b": "overlay" }, + lyricOffsets: { "lyric-a": { x: 1, y: -2 } }, + fontOverrides: { "voice-b": { role: "special", scaleX: 1.1, scaleY: 0.9 } } + }}); + store.execute({ type: "set-voice-layout", voiceId: "voice-b", patch: { temporary: true, fontRole: "special", alignment: "overlay" } }); + const temporary = { id: "voice-temp", name: "临时声部", sectionId: null, visible: true, temporary: true, alignment: "independent", measures: [measure("measure-t-1", 0, 6)] }; + store.execute({ type: "add-temporary-voice", voice: temporary }); + const reopened = decodeProject(encodeProject(store.snapshot())); + assert.equal(modelEquivalent(store.snapshot(), reopened), true); + assert.deepEqual(reopened.manualLayout.measurePositions["measure-a-1"], { x: 3, y: 2 }); + assert.deepEqual(reopened.manualLayout.objectPositions["measure-a-1-event"], { x: 1.5, y: -0.5 }); + assert.equal(reopened.voices.find((voice) => voice.id === "voice-b").temporary, true); + assert.equal(reopened.voices.find((voice) => voice.id === "voice-temp").temporary, true); + const report = preflightJpwabcExport(reopened); + assert.equal(report.safe, false); + assert.ok(report.reasons.some((reason) => reason.includes("手动排版") || reason.includes("临时"))); +}); + +test("M7 layout consumes lock, forced break, measure movement, voice order and voice spacing", async () => { + const document = baseDocument(); + document.manualLayout.lockedMeasureIds = ["measure-a-1"]; + document.manualLayout.forcedBreakAfterMeasureIds = ["measure-a-1"]; + document.manualLayout.measureSpacing["measure-a-1"] = 12; + document.manualLayout.measurePositions = { "measure-a-1": { x: 4, y: 2 } }; + document.manualLayout.objectPositions = { "measure-a-1-event": { x: 1.5, y: -0.5 } }; + document.manualLayout.voiceOrder = ["voice-b", "voice-a"]; + document.manualLayout.voiceSpacing = { "voice-b": 6 }; + document.voices[1].alignment = "overlay"; + const snapshot = await layoutDocument(document, { ...DEFAULT_PAPER_CONFIG, lineHeightMm: 25 }, createDefaultFontMeasurementProvider()); + const locked = snapshot.measures["measure-a-1"]; + assert.equal(locked.locked, true); + assert.equal(locked.voiceId, "voice-a"); + assert.equal(locked.x, 128); + assert.equal(locked.y, 37); + assert.ok(snapshot.pages.some((page) => page.lines.some((line) => line.measureIds.includes("measure-a-1")))); + assert.ok(snapshot.pages.flatMap((page) => page.lines).some((line) => line.measureIds.includes("measure-a-2"))); + assert.equal(snapshot.symbols["measure-a-1-event"].voiceId, "voice-a"); + assert.equal(snapshot.symbols["measure-b-1-event"].voiceId, "voice-b"); +}); + +test("M7 measure insert/delete preserves ordinals and diagnoses removed anchors", () => { + const store = new CommandStore(baseDocument()); + const voice = store.snapshot().voices[0]; + store.execute({ type: "insert-measure", voiceId: voice.id, index: 1, measure: { id: "measure-inserted", ordinal: 1, beats: rational(1), events: [measure("measure-inserted", 1, 4).events[0]] } }); + assert.equal(store.snapshot().voices[0].measures.length, 4); + assert.deepEqual(store.snapshot().voices[0].measures.map((item) => item.ordinal), [0, 1, 2, 3]); + store.execute({ type: "delete-measure", voiceId: voice.id, measureId: "measure-inserted" }); + assert.equal(store.snapshot().voices[0].measures.length, 3); + assert.deepEqual(store.snapshot().voices[0].measures.map((item) => item.ordinal), [0, 1, 2]); +}); + +test("M7 page text boxes and voice names are editable and persist", () => { + const store = new CommandStore(baseDocument()); + store.execute({ type: "set-voice-layout", voiceId: "voice-a", patch: { name: "主旋律改名", alignment: "independent" } }); + store.execute({ type: "insert-page-object", object: { id: "page-note", kind: "TextBox", raw: "页面备注", sourceRange: null, position: { x: 20, y: 270 }, visible: true } }); + store.execute({ type: "update-page-object", objectId: "page-note", patch: { raw: "页面备注2", position: { x: 22, y: 268 } } }); + const reopened = decodeProject(encodeProject(store.snapshot())); + assert.equal(reopened.voices[0].name, "主旋律改名"); + assert.deepEqual(reopened.pageObjects.find((object) => object.id === "page-note")?.position, { x: 22, y: 268 }); + store.execute({ type: "delete-page-object", objectId: "page-note" }); + assert.equal(store.snapshot().pageObjects.some((object) => object.id === "page-note"), false); +}); + +test("M7 voice groups support split, merge, undo and explicit mismatch diagnostics", () => { + const store = new CommandStore(baseDocument()); + store.execute({ type: "split-voice", voiceId: "voice-a", atMeasure: 1, newVoiceId: "voice-a-split", newName: "主旋律 B" }); + assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a")?.measures.length, 1); + assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a-split")?.measures.length, 2); + store.undo(); + assert.equal(store.snapshot().voices.some((voice) => voice.id === "voice-a-split"), false); + store.redo(); + store.execute({ type: "merge-voices", targetVoiceId: "voice-a", sourceVoiceId: "voice-a-split", strategy: "append" }); + assert.equal(store.snapshot().voices.find((voice) => voice.id === "voice-a")?.measures.length, 3); + const mismatch = baseDocument(); + mismatch.voices[1].alignment = "aligned"; + mismatch.voices[1].measures = mismatch.voices[1].measures.slice(0, 1); + const diagnostics = validateScore(mismatch).diagnostics; + assert.ok(diagnostics.some((diagnostic) => diagnostic.code === "VOICE_MEASURE_COUNT_MISMATCH")); +}); + +test("M7 temporary voice can be removed without deleting primary voices", () => { + const store = new CommandStore(baseDocument()); + const temporary = { id: "voice-temp", name: "临时", sectionId: null, visible: true, temporary: true, measures: [measure("measure-t-1", 0, 4)] }; + store.execute({ type: "add-temporary-voice", voice: temporary }); + store.execute({ type: "delete-voice", voiceId: "voice-temp" }); + assert.equal(store.snapshot().voices.some((voice) => voice.id === "voice-temp"), false); + assert.throws(() => store.execute({ type: "delete-voice", voiceId: "voice-a" }), /主声部不可/); +}); diff --git a/test/m8.test.mjs b/test/m8.test.mjs new file mode 100644 index 0000000..c8f0817 --- /dev/null +++ b/test/m8.test.mjs @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { compilePlaybackTimeline, PlaybackController, RecordingPlaybackSink } from "../dist/playback/playback.js"; +import { createEmptyDocument, createNoteEvent, rational } from "../dist/domain/score-model.js"; + +function note(id, degree, raw = id) { + const event = createNoteEvent(id, degree, rational(1)); + event.source = { range: null, raw }; + return event; +} + +function measure(id, ordinal, events, patch = {}) { + return { id, ordinal, beats: rational(1), events, ...patch }; +} + +test("M8 expands repeat start/end and first/second endings without an unbounded loop", () => { + const document = createEmptyDocument("m8-repeat"); + document.voices.push({ id: "voice-repeat", name: "反复", sectionId: null, visible: true, measures: [ + measure("m1", 0, [note("n1", 1)], { repeatStart: true }), + measure("m2", 1, [note("n2", 2)], { repeatEnd: true, ending: 1 }), + measure("m3", 2, [note("n3", 3)], { ending: 2 }) + ] }); + const timeline = compilePlaybackTimeline(document); + assert.deepEqual(timeline.events.filter((event) => event.kind === "note").map((event) => event.sourceEvent.id), ["n1", "n2", "n1", "n3"]); + assert.equal(timeline.durationTicks, 3840); + assert.equal(timeline.loopIterations, 2); + assert.equal(timeline.warnings.some((warning) => warning.includes("安全迭代")), false); +}); + +test("M8 handles DC/DS navigation with a bounded jump and exposes control state", () => { + const document = createEmptyDocument("m8-navigation"); + const dc = note("dc", 2, "{DC}"); + dc.decorations = [{ id: "dc-mark", kind: "DC", raw: "{DC}", playback: "visual", sourceRange: null }]; + const ds = note("ds", 3, "{DS}"); + ds.decorations = [{ id: "ds-mark", kind: "DS", raw: "{DS}", playback: "visual", sourceRange: null }]; + document.voices.push({ id: "voice-nav", name: "导航", sectionId: null, visible: true, measures: [measure("nav-1", 0, [note("nav-1-note", 1)], { repeatStart: true }), measure("nav-2", 1, [dc]), measure("nav-3", 2, [ds])] }); + const timeline = compilePlaybackTimeline(document); + assert.ok(timeline.events.length >= 6); + assert.ok(timeline.controlEvents?.some((event) => event.type === "navigation" && event.value === "DC")); + assert.ok(timeline.loopIterations >= 2); +}); + +test("M8 restores tempo, meter and key state at control ticks", () => { + const document = createEmptyDocument("m8-controls"); + document.title.tempo = 60; + document.title.meter = "4/4"; + document.title.key = "C"; + document.voices.push({ id: "voice-controls", name: "控制", sectionId: null, visible: true, measures: [ + measure("control-1", 0, [note("control-1-note", 1)]), + measure("control-2", 1, [note("control-2-note", 1)], { tempoBpm: 120, meter: { numerator: 3, denominator: 4 }, key: "G" }) + ] }); + const timeline = compilePlaybackTimeline(document); + assert.equal(timeline.stateAtTick(0).tempoBpm, 60); + assert.equal(timeline.stateAtTick(960).tempoBpm, 120); + assert.deepEqual(timeline.stateAtTick(960).meter, { numerator: 3, denominator: 4 }); + assert.equal(timeline.stateAtTick(960).key, "G"); + assert.ok(Math.abs(timeline.secondsAtTick(1920) - 1.5) < 1e-12); + assert.ok(Math.abs(timeline.ticksAtSeconds(1.5) - 1920) < 1e-9); +}); + +test("M8 maps W1 dynamics, staccato, tuplets and chord tones to playback events", () => { + const document = createEmptyDocument("m8-w1"); + const event = note("w1-note", 1, "1"); + event.chord = [{ degree: 1, accidental: 0, octave: 0 }, { degree: 3, accidental: 0, octave: 0 }]; + event.tuplet = { id: "tuplet-1", ratio: 3, ordinal: 0, raw: "{(3}" }; + event.decorations = [ + { id: "dynamic", kind: "mf", raw: "{mf}", playback: "w1", sourceRange: null }, + { id: "staccato", kind: "staccato", raw: "{staccato}", playback: "w1", sourceRange: null } + ]; + document.voices.push({ id: "voice-w1", name: "W1", sectionId: null, visible: true, measures: [measure("w1-measure", 0, [event])] }); + const timeline = compilePlaybackTimeline(document); + const notes = timeline.events.filter((candidate) => candidate.kind === "note"); + assert.equal(notes.length, 2); + assert.equal(notes[0].durationTicks, 640); + assert.equal(notes[0].gateTicks, 320); + assert.equal(notes[0].velocity, 82); + assert.ok(timeline.controlEvents?.some((control) => control.type === "velocity")); +}); + +test("M8 controller schedules gate duration from the tempo segment containing the note", async () => { + const document = createEmptyDocument("m8-controller"); + document.title.tempo = 60; + document.voices.push({ id: "voice", name: "控制器", sectionId: null, visible: true, measures: [measure("m1", 0, [note("n1", 1)]), measure("m2", 1, [note("n2", 2)], { tempoBpm: 120 })] }); + const timeline = compilePlaybackTimeline(document); + const sink = new RecordingPlaybackSink(); + const controller = new PlaybackController(timeline, { sink }); + await controller.play(); + assert.equal(sink.scheduled.length, 2); + assert.ok(Math.abs(sink.scheduled[1].durationSeconds - 0.5) < 1e-12); + controller.destroy(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..22fe75e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "noEmitOnError": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +}