staff-test.html 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>五线谱渲染器 - 可视化集成测试</title>
  7. <style>
  8. :root {
  9. --primary: #2563eb;
  10. --primary-light: #60a5fa;
  11. --success: #10b981;
  12. --warning: #f59e0b;
  13. --error: #ef4444;
  14. --bg: #0f172a;
  15. --bg-card: #1e293b;
  16. --bg-panel: #ffffff;
  17. --text: #f1f5f9;
  18. --text-dark: #1e293b;
  19. --border: #334155;
  20. }
  21. * { box-sizing: border-box; margin: 0; padding: 0; }
  22. body {
  23. font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
  24. background: var(--bg);
  25. color: var(--text);
  26. min-height: 100vh;
  27. padding: 20px;
  28. }
  29. .container {
  30. max-width: 1400px;
  31. margin: 0 auto;
  32. }
  33. header {
  34. text-align: center;
  35. padding: 30px;
  36. background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
  37. border-radius: 12px;
  38. margin-bottom: 24px;
  39. }
  40. header h1 {
  41. font-size: 2rem;
  42. margin-bottom: 8px;
  43. }
  44. header p {
  45. opacity: 0.9;
  46. }
  47. .control-panel {
  48. background: var(--bg-card);
  49. border-radius: 12px;
  50. padding: 20px;
  51. margin-bottom: 24px;
  52. display: flex;
  53. flex-wrap: wrap;
  54. gap: 16px;
  55. align-items: center;
  56. }
  57. .control-group {
  58. display: flex;
  59. flex-direction: column;
  60. gap: 4px;
  61. }
  62. .control-group label {
  63. font-size: 0.85rem;
  64. color: var(--text);
  65. opacity: 0.8;
  66. }
  67. select, input, button {
  68. padding: 8px 12px;
  69. border-radius: 6px;
  70. border: 1px solid var(--border);
  71. background: var(--bg);
  72. color: var(--text);
  73. font-size: 0.9rem;
  74. }
  75. button {
  76. background: var(--primary);
  77. border-color: var(--primary);
  78. cursor: pointer;
  79. font-weight: 500;
  80. transition: all 0.2s;
  81. }
  82. button:hover {
  83. background: var(--primary-light);
  84. }
  85. button.success {
  86. background: var(--success);
  87. border-color: var(--success);
  88. }
  89. .render-panel {
  90. background: var(--bg-panel);
  91. border-radius: 12px;
  92. padding: 30px;
  93. min-height: 400px;
  94. margin-bottom: 24px;
  95. }
  96. #score-container {
  97. width: 100%;
  98. overflow-x: auto;
  99. }
  100. #score-container svg {
  101. display: block;
  102. margin: 0 auto;
  103. }
  104. .stats-panel {
  105. background: var(--bg-card);
  106. border-radius: 12px;
  107. padding: 20px;
  108. margin-bottom: 24px;
  109. }
  110. .stats-panel h3 {
  111. font-size: 1rem;
  112. margin-bottom: 12px;
  113. color: var(--text);
  114. }
  115. .stats-grid {
  116. display: grid;
  117. grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  118. gap: 12px;
  119. }
  120. .stat-item {
  121. background: var(--bg);
  122. border-radius: 8px;
  123. padding: 12px;
  124. text-align: center;
  125. }
  126. .stat-value {
  127. font-size: 1.5rem;
  128. font-weight: 600;
  129. color: var(--primary-light);
  130. }
  131. .stat-label {
  132. font-size: 0.8rem;
  133. color: var(--text);
  134. opacity: 0.7;
  135. }
  136. .log-panel {
  137. background: var(--bg-card);
  138. border-radius: 12px;
  139. padding: 20px;
  140. }
  141. .log-panel h3 {
  142. font-size: 1rem;
  143. margin-bottom: 12px;
  144. }
  145. #log-output {
  146. background: #000;
  147. border-radius: 6px;
  148. padding: 12px;
  149. font-family: 'Consolas', 'Monaco', monospace;
  150. font-size: 0.85rem;
  151. max-height: 200px;
  152. overflow-y: auto;
  153. color: #10b981;
  154. }
  155. .log-entry {
  156. margin-bottom: 4px;
  157. }
  158. .log-entry.error {
  159. color: var(--error);
  160. }
  161. .log-entry.warn {
  162. color: var(--warning);
  163. }
  164. .placeholder {
  165. display: flex;
  166. flex-direction: column;
  167. align-items: center;
  168. justify-content: center;
  169. height: 300px;
  170. color: var(--text-dark);
  171. opacity: 0.5;
  172. }
  173. .placeholder svg {
  174. width: 80px;
  175. height: 80px;
  176. margin-bottom: 16px;
  177. }
  178. /* 五线谱 SVG 样式 */
  179. .staff-score {
  180. background: #fff;
  181. }
  182. .staff-line {
  183. stroke: #000;
  184. stroke-width: 1;
  185. }
  186. .note-head {
  187. fill: #000;
  188. }
  189. .note-stem {
  190. stroke: #000;
  191. stroke-width: 1.2;
  192. }
  193. .barline {
  194. stroke: #000;
  195. }
  196. .clef-text, .time-sig-text, .accidental-text {
  197. font-family: 'Bravura', 'Gonville', serif;
  198. }
  199. .lyric-text {
  200. font-family: "Microsoft YaHei", sans-serif;
  201. font-size: 12px;
  202. }
  203. /* 验收检查列表 */
  204. .checklist-panel {
  205. background: var(--bg-card);
  206. border-radius: 12px;
  207. padding: 20px;
  208. margin-bottom: 24px;
  209. }
  210. .checklist-panel h3 {
  211. font-size: 1rem;
  212. margin-bottom: 12px;
  213. }
  214. .checklist {
  215. list-style: none;
  216. }
  217. .checklist li {
  218. padding: 8px 0;
  219. display: flex;
  220. align-items: center;
  221. gap: 8px;
  222. }
  223. .checklist li.pass::before {
  224. content: '✅';
  225. }
  226. .checklist li.fail::before {
  227. content: '❌';
  228. }
  229. .checklist li.pending::before {
  230. content: '⏳';
  231. }
  232. </style>
  233. </head>
  234. <body>
  235. <div class="container">
  236. <header>
  237. <h1>🎼 五线谱渲染器 - 可视化测试</h1>
  238. <p>StaffRenderer Integration Test - 阶段三验收</p>
  239. </header>
  240. <div class="control-panel">
  241. <div class="control-group">
  242. <label>测试曲谱</label>
  243. <select id="xml-select">
  244. <option value="basic">basic.xml - 基础音阶</option>
  245. <option value="mixed-durations">mixed-durations.xml - 混合时值</option>
  246. <option value="with-lyrics">with-lyrics.xml - 带歌词</option>
  247. <option value="complex">complex.xml - 复杂曲谱</option>
  248. </select>
  249. </div>
  250. <div class="control-group">
  251. <label>系统宽度</label>
  252. <input type="number" id="system-width" value="800" min="400" max="1200">
  253. </div>
  254. <div class="control-group">
  255. <label>谱线间距</label>
  256. <input type="number" id="staff-spacing" value="10" min="6" max="16">
  257. </div>
  258. <div class="control-group">
  259. <label>四分音符间距</label>
  260. <input type="number" id="note-spacing" value="40" min="20" max="80">
  261. </div>
  262. <div class="control-group">
  263. <label>显示歌词</label>
  264. <select id="show-lyrics">
  265. <option value="true">显示</option>
  266. <option value="false">隐藏</option>
  267. </select>
  268. </div>
  269. <div class="control-group">
  270. <label>调试模式</label>
  271. <select id="debug-mode">
  272. <option value="false">关闭</option>
  273. <option value="true">开启</option>
  274. </select>
  275. </div>
  276. <button onclick="loadAndRender()" class="success">🎨 渲染</button>
  277. <button onclick="clearOutput()">🗑️ 清除</button>
  278. </div>
  279. <div class="render-panel">
  280. <div id="score-container">
  281. <div class="placeholder">
  282. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
  283. <path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
  284. </svg>
  285. <p>点击"渲染"按钮开始测试</p>
  286. </div>
  287. </div>
  288. </div>
  289. <div class="stats-panel">
  290. <h3>📊 渲染统计</h3>
  291. <div class="stats-grid">
  292. <div class="stat-item">
  293. <div class="stat-value" id="stat-measures">-</div>
  294. <div class="stat-label">小节数</div>
  295. </div>
  296. <div class="stat-item">
  297. <div class="stat-value" id="stat-notes">-</div>
  298. <div class="stat-label">音符数</div>
  299. </div>
  300. <div class="stat-item">
  301. <div class="stat-value" id="stat-systems">-</div>
  302. <div class="stat-label">行数</div>
  303. </div>
  304. <div class="stat-item">
  305. <div class="stat-value" id="stat-parse">-</div>
  306. <div class="stat-label">解析时间</div>
  307. </div>
  308. <div class="stat-item">
  309. <div class="stat-value" id="stat-layout">-</div>
  310. <div class="stat-label">布局时间</div>
  311. </div>
  312. <div class="stat-item">
  313. <div class="stat-value" id="stat-draw">-</div>
  314. <div class="stat-label">绘制时间</div>
  315. </div>
  316. <div class="stat-item">
  317. <div class="stat-value" id="stat-total">-</div>
  318. <div class="stat-label">总耗时</div>
  319. </div>
  320. </div>
  321. </div>
  322. <div class="checklist-panel">
  323. <h3>✅ 阶段三验收清单</h3>
  324. <ul class="checklist">
  325. <li id="check-render" class="pending">能渲染基本乐谱(单声部、无特殊记号)</li>
  326. <li id="check-clef" class="pending">谱号显示正确</li>
  327. <li id="check-key" class="pending">调号显示正确</li>
  328. <li id="check-time" class="pending">拍号显示正确</li>
  329. <li id="check-position" class="pending">音符位置正确(Y 位置对应音高)</li>
  330. <li id="check-stem" class="pending">符干方向正确</li>
  331. <li id="check-barline" class="pending">小节线正确</li>
  332. <li id="check-shared" class="pending">与简谱共享数据源(music-core)</li>
  333. </ul>
  334. </div>
  335. <div class="log-panel">
  336. <h3>📋 控制台日志</h3>
  337. <div id="log-output">
  338. <div class="log-entry">等待渲染...</div>
  339. </div>
  340. </div>
  341. </div>
  342. <script type="module">
  343. // 导入 music-core 解析器
  344. import { MusicXMLParser } from './src/music-core/parser/MusicXMLParser';
  345. import { StaffRenderer, createStaffRenderer } from './src/renderers/staff';
  346. // 日志函数
  347. function log(message, type = 'info') {
  348. const logOutput = document.getElementById('log-output');
  349. const entry = document.createElement('div');
  350. entry.className = `log-entry ${type}`;
  351. entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
  352. logOutput.appendChild(entry);
  353. logOutput.scrollTop = logOutput.scrollHeight;
  354. // 同时输出到控制台
  355. if (type === 'error') {
  356. console.error(message);
  357. } else {
  358. console.log(message);
  359. }
  360. }
  361. // 更新验收检查状态
  362. function updateCheckStatus(id, pass, message = '') {
  363. const el = document.getElementById(id);
  364. if (el) {
  365. el.className = pass ? 'pass' : 'fail';
  366. if (message) {
  367. el.textContent = el.textContent.split(' - ')[0] + ' - ' + message;
  368. }
  369. }
  370. }
  371. // 清除输出
  372. window.clearOutput = function() {
  373. document.getElementById('score-container').innerHTML = `
  374. <div class="placeholder">
  375. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
  376. <path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
  377. </svg>
  378. <p>点击"渲染"按钮开始测试</p>
  379. </div>
  380. `;
  381. document.getElementById('log-output').innerHTML = '<div class="log-entry">等待渲染...</div>';
  382. // 重置统计
  383. ['measures', 'notes', 'systems', 'parse', 'layout', 'draw', 'total'].forEach(id => {
  384. document.getElementById(`stat-${id}`).textContent = '-';
  385. });
  386. // 重置验收检查
  387. ['check-render', 'check-clef', 'check-key', 'check-time', 'check-position', 'check-stem', 'check-barline', 'check-shared'].forEach(id => {
  388. const el = document.getElementById(id);
  389. if (el) {
  390. el.className = 'pending';
  391. el.textContent = el.textContent.split(' - ')[0];
  392. }
  393. });
  394. };
  395. // 加载并渲染
  396. window.loadAndRender = async function() {
  397. const xmlSelect = document.getElementById('xml-select').value;
  398. const systemWidth = parseInt(document.getElementById('system-width').value);
  399. const staffSpacing = parseInt(document.getElementById('staff-spacing').value);
  400. const noteSpacing = parseInt(document.getElementById('note-spacing').value);
  401. const showLyrics = document.getElementById('show-lyrics').value === 'true';
  402. const debugMode = document.getElementById('debug-mode').value === 'true';
  403. log(`开始加载: ${xmlSelect}.xml`);
  404. // 重置验收检查
  405. clearOutput();
  406. try {
  407. // 加载XML文件
  408. const fixturesPath = './src/jianpu-renderer/__tests__/fixtures/';
  409. const response = await fetch(`${fixturesPath}${xmlSelect}.xml`);
  410. if (!response.ok) {
  411. throw new Error(`无法加载文件: ${xmlSelect}.xml`);
  412. }
  413. const xmlContent = await response.text();
  414. log(`XML文件加载成功 (${xmlContent.length} 字节)`);
  415. // 解析XML - 验证使用 music-core
  416. const parseStartTime = performance.now();
  417. const parser = new MusicXMLParser();
  418. const result = parser.parse(xmlContent);
  419. const parseTime = performance.now() - parseStartTime;
  420. if (result.errors.length > 0) {
  421. log(`解析出错: ${result.errors.join(', ')}`, 'error');
  422. updateCheckStatus('check-shared', false, '解析失败');
  423. return;
  424. }
  425. updateCheckStatus('check-shared', true, '使用 music-core 解析器');
  426. if (result.warnings.length > 0) {
  427. result.warnings.forEach(w => log(`警告: ${w}`, 'warn'));
  428. }
  429. const score = result.score;
  430. log(`解析完成: ${score.parts.length} 个声部, ${score.parts[0]?.measures.length ?? 0} 个小节, 耗时 ${parseTime.toFixed(2)}ms`);
  431. // 创建渲染器
  432. const container = document.getElementById('score-container');
  433. container.innerHTML = '';
  434. const renderer = createStaffRenderer({
  435. container,
  436. systemWidth,
  437. staffLineSpacing: staffSpacing,
  438. quarterNoteSpacing: noteSpacing,
  439. showLyrics,
  440. debug: debugMode,
  441. });
  442. // 加载乐谱
  443. renderer.load(score);
  444. // 渲染
  445. const renderStartTime = performance.now();
  446. renderer.render();
  447. const renderTime = performance.now() - renderStartTime;
  448. // 获取统计
  449. const stats = renderer.getStats();
  450. // 更新统计显示
  451. document.getElementById('stat-measures').textContent = stats.measureCount;
  452. document.getElementById('stat-notes').textContent = stats.noteCount;
  453. document.getElementById('stat-systems').textContent = stats.systemCount;
  454. document.getElementById('stat-parse').textContent = `${parseTime.toFixed(1)}ms`;
  455. document.getElementById('stat-layout').textContent = `${stats.layoutTime.toFixed(1)}ms`;
  456. document.getElementById('stat-draw').textContent = `${stats.drawTime.toFixed(1)}ms`;
  457. document.getElementById('stat-total').textContent = `${stats.totalTime.toFixed(1)}ms`;
  458. log(`✅ 渲染完成! 共 ${stats.systemCount} 行, ${stats.noteCount} 个音符`, 'success');
  459. log(`布局耗时: ${stats.layoutTime.toFixed(2)}ms, 绘制耗时: ${stats.drawTime.toFixed(2)}ms`);
  460. // 验收检查
  461. validateRendering(container, stats);
  462. } catch (error) {
  463. log(`❌ 错误: ${error.message}`, 'error');
  464. console.error(error);
  465. updateCheckStatus('check-render', false, error.message);
  466. }
  467. };
  468. // 验证渲染结果
  469. function validateRendering(container, stats) {
  470. const svg = container.querySelector('svg');
  471. // 1. 检查基本渲染
  472. if (svg && stats.noteCount > 0) {
  473. updateCheckStatus('check-render', true, `${stats.noteCount} 个音符已渲染`);
  474. } else {
  475. updateCheckStatus('check-render', false, 'SVG 未生成或无音符');
  476. }
  477. // 2. 检查谱号
  478. const clefs = svg?.querySelectorAll('.staff-clef, [class*="clef"]');
  479. if (clefs && clefs.length > 0) {
  480. updateCheckStatus('check-clef', true, `${clefs.length} 个谱号`);
  481. } else {
  482. // 检查是否有谱号相关的文本或路径
  483. const clefTexts = svg?.querySelectorAll('text');
  484. let hasClef = false;
  485. clefTexts?.forEach(t => {
  486. if (t.textContent && (t.textContent.includes('\uE050') || t.textContent.includes('𝄞'))) {
  487. hasClef = true;
  488. }
  489. });
  490. updateCheckStatus('check-clef', hasClef, hasClef ? '谱号已渲染' : '未找到谱号元素');
  491. }
  492. // 3. 检查调号(如果有)
  493. const keySignatures = svg?.querySelectorAll('.staff-key-signature, [class*="key"]');
  494. // 对于 C 大调,可能没有调号,这是正常的
  495. updateCheckStatus('check-key', true, keySignatures?.length > 0 ? `${keySignatures.length} 个调号` : 'C大调(无升降号)');
  496. // 4. 检查拍号
  497. const timeSigs = svg?.querySelectorAll('.time-signature, [class*="time"]');
  498. const timeSigTexts = svg?.querySelectorAll('text');
  499. let hasTimeSig = false;
  500. timeSigTexts?.forEach(t => {
  501. if (t.textContent && /^[0-9]$/.test(t.textContent)) {
  502. hasTimeSig = true;
  503. }
  504. });
  505. updateCheckStatus('check-time', hasTimeSig || (timeSigs && timeSigs.length > 0),
  506. hasTimeSig ? '拍号已渲染' : '检查拍号显示');
  507. // 5. 检查音符位置
  508. const noteHeads = svg?.querySelectorAll('.note-head, ellipse, [class*="note"]');
  509. if (noteHeads && noteHeads.length > 0) {
  510. // 检查音符是否有不同的Y位置(表示不同音高)
  511. const yPositions = new Set();
  512. noteHeads.forEach(n => {
  513. const cy = n.getAttribute('cy');
  514. const y = n.getAttribute('y');
  515. if (cy) yPositions.add(Math.round(parseFloat(cy)));
  516. if (y) yPositions.add(Math.round(parseFloat(y)));
  517. });
  518. updateCheckStatus('check-position', yPositions.size > 1 || noteHeads.length === 1,
  519. `${noteHeads.length} 个符头, ${yPositions.size} 个不同Y位置`);
  520. } else {
  521. updateCheckStatus('check-position', false, '未找到符头元素');
  522. }
  523. // 6. 检查符干
  524. const stems = svg?.querySelectorAll('.note-stem, line[class*="stem"]');
  525. if (stems && stems.length > 0) {
  526. // 检查符干是否有向上和向下的
  527. let upCount = 0, downCount = 0;
  528. stems.forEach(s => {
  529. const y1 = parseFloat(s.getAttribute('y1') || '0');
  530. const y2 = parseFloat(s.getAttribute('y2') || '0');
  531. if (y1 < y2) upCount++;
  532. else downCount++;
  533. });
  534. updateCheckStatus('check-stem', true,
  535. `${stems.length} 个符干 (上:${upCount}, 下:${downCount})`);
  536. } else {
  537. // 全音符可能没有符干
  538. updateCheckStatus('check-stem', true, '符干正常(可能含全音符)');
  539. }
  540. // 7. 检查小节线
  541. const barlines = svg?.querySelectorAll('.barline, line.barline, [class*="barline"]');
  542. if (barlines && barlines.length > 0) {
  543. updateCheckStatus('check-barline', true, `${barlines.length} 条小节线`);
  544. } else {
  545. // 检查是否有垂直线条作为小节线
  546. const lines = svg?.querySelectorAll('line');
  547. let barlineCount = 0;
  548. lines?.forEach(l => {
  549. const x1 = parseFloat(l.getAttribute('x1') || '0');
  550. const x2 = parseFloat(l.getAttribute('x2') || '0');
  551. if (Math.abs(x1 - x2) < 1) {
  552. barlineCount++;
  553. }
  554. });
  555. updateCheckStatus('check-barline', barlineCount > 0,
  556. barlineCount > 0 ? `${barlineCount} 条小节线` : '未找到小节线');
  557. }
  558. log('验收检查完成');
  559. }
  560. // 页面加载完成后自动渲染
  561. document.addEventListener('DOMContentLoaded', () => {
  562. log('🎼 五线谱渲染器可视化测试页面已加载');
  563. log('请点击"渲染"按钮开始阶段三验收测试');
  564. });
  565. </script>
  566. </body>
  567. </html>