# 音乐乐理考试试卷结构规范

## 一、标准试卷 HTML 完整模板

> ⚠️ **重要**：此模板已经过完整调试验证，必须严格遵循，不得使用旧的 EasyScore/Factory/System API，不得使用静态 `<script src>` 引入 VexFlow。

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>基本乐理期末考试试卷</title>
  <!-- ❌ 禁止在此处用 <script src> 引入 VexFlow，Edge 跟踪防护会拦截 -->
  <style>
    @page { size: A4; margin: 20mm 15mm 20mm 20mm; }
    * { box-sizing: border-box; }
    body {
      font-family: "SimSun", "宋体", "Times New Roman", serif;
      font-size: 10.5pt;
      line-height: 1.5;
      color: #000;
      background: #fff;
      margin: 0;
      padding: 20px;
    }
    .header { text-align: center; margin-bottom: 20px; font-family: "SimHei", "黑体", serif; }
    .school-name { font-size: 22pt; font-weight: bold; margin-bottom: 10px; letter-spacing: 2px; }
    .exam-title { font-size: 18pt; font-weight: bold; margin-bottom: 10px; }
    .exam-info {
      font-size: 12pt;
      border-bottom: 1px solid #000;
      padding-bottom: 10px;
      margin-bottom: 15px;
      display: flex;
      justify-content: center;
      gap: 30px;
    }
    table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
    td, th { border: 1px solid #000; padding: 6px; text-align: center; font-size: 10.5pt; }
    .info-table td.label { background: #f0f0f0; font-weight: bold; width: 80px; }
    .info-table td.fill { text-align: left; }
    .notice-box { border: 1px solid #000; padding: 8px; margin-bottom: 15px; font-size: 9pt; line-height: 1.4; }
    .score-table th { background: #f0f0f0; height: 35px; }
    .score-table td { height: 35px; }
    .section { margin-top: 25px; page-break-inside: avoid; }
    .section-title { font-size: 12pt; font-weight: bold; margin-bottom: 15px; font-family: "SimHei", "黑体", serif; }
    .question { margin-bottom: 20px; page-break-inside: avoid; }
    .question-text { margin-bottom: 10px; display: flex; justify-content: space-between; }
    /* ✅ 不设 min-height，不设 overflow:hidden，避免音符被裁剪 */
    .staff-container { width: 100%; overflow: visible; margin: 10px 0; position: relative; }
    @media print { body { padding: 0; } }
  </style>
</head>
<body>

<div class="header">
  <div class="school-name">音乐学院</div>
  <div class="exam-title">《基本乐理》课程期末考试试卷</div>
  <div class="exam-info">
    <span>2024—2025学年第1学期</span>
    <span>考试时间：120分钟</span>
    <span>满分：100分</span>
  </div>
</div>

<table class="info-table">
  <tr>
    <td class="label">姓 名</td><td class="fill" style="width:20%;"></td>
    <td class="label">学 号</td><td class="fill" style="width:20%;"></td>
    <td class="label">班 级</td><td class="fill"></td>
  </tr>
</table>

<div class="notice-box">
  <strong>注意事项：</strong>
  1. 答题前请检查试卷页数是否齐全。
  2. 请将姓名、学号、班级填写在相应位置。
  3. 所有答案须写在试卷指定位置，写在其他位置无效。
  4. 考试结束后，将试卷交给监考老师。
</div>

<table class="score-table">
  <tr>
    <th style="width:80px;">题号</th>
    <th>一</th><th>二</th><th>三</th><th>四</th><th>五</th><th>六</th><th>七</th>
    <th>总分</th><th>阅卷人</th>
  </tr>
  <tr>
    <td>得分</td>
    <td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>
  </tr>
</table>

<div id="exam-content">
  <!-- 题目内容插入此处，参见下方各题型模板 -->
</div>

<!-- ✅ 所有渲染逻辑集中在 body 底部，VexFlow 动态加载 -->
<script>
(function() {
  // A4 打印可用宽度：210mm - 左边距20mm - 右边距15mm = 175mm ≈ 621px（96dpi）
  var PRINT_WIDTH = 621;

  function renderAllWithWidth(W_override) {
    if (typeof Vex === 'undefined') return;
    var VF = Vex.Flow;

    function getW(id) {
      if (W_override) return W_override;
      return document.getElementById(id).offsetWidth || 700;
    }

    // 创建渲染上下文，每次调用前自动清空旧 SVG
    function makeCtx(id, h) {
      var w = getW(id);
      var div = document.getElementById(id);
      div.innerHTML = '';
      var renderer = new VF.Renderer(div, VF.Renderer.Backends.SVG);
      renderer.resize(w, h);
      return { ctx: renderer.getContext(), w: w };
    }

    // 渲染完成后，按音符实际 x 坐标对齐标签
    function alignLabels(labelId, notes) {
      var container = document.getElementById(labelId);
      if (!container) return;
      var items = container.querySelectorAll('span');
      notes.forEach(function(note, i) {
        if (items[i]) {
          items[i].style.position = 'absolute';
          items[i].style.left = (note.getAbsoluteX() - 22) + 'px';
          items[i].style.width = '60px';
          items[i].style.textAlign = 'center';
        }
      });
    }

    // ---- 在此处添加各题渲染函数 ----
    // 示例见下方各题型模板

  }

  function renderAll() {
    if (typeof Vex === 'undefined') {
      document.getElementById('exam-content').insertAdjacentHTML('afterbegin',
        '<p style="color:red;border:1px dashed red;padding:8px;">错误：VexFlow 库加载失败，请检查网络连接后刷新页面。</p>'
      );
      return;
    }
    renderAllWithWidth(null);
    window.addEventListener('beforeprint', function() { renderAllWithWidth(PRINT_WIDTH); });
    window.addEventListener('afterprint', function() { renderAllWithWidth(null); });
  }

  // ✅ 动态注入 script 标签，绕过 Edge 跟踪防护
  function loadScript(src, ok, fail) {
    var s = document.createElement('script');
    s.src = src; s.onload = ok; s.onerror = fail;
    document.head.appendChild(s);
  }

  var CDN1 = 'https://cdn.jsdelivr.net/npm/vexflow@4.2.2/build/cjs/vexflow.js';
  var CDN2 = 'https://unpkg.com/vexflow@4.2.2/build/cjs/vexflow.js';
  loadScript(CDN1, renderAll, function() {
    loadScript(CDN2, renderAll, renderAll);
  });
})();
</script>

</body>
</html>
```

---

## 二、各题型 HTML + 渲染代码模板

> ⚠️ **关键规则**：
> - HTML 中的五线谱容器只放空 `<div>`，**不放任何 `<script>`**
> - 所有渲染代码写在 body 底部的 `renderAllWithWidth` 函数内
> - **禁止使用** `EasyScore`、`Factory`、`System` API
> - **必须使用** `VF.Stave` + `VF.StaveNote` + `VF.Voice.setStrict(false)` + `VF.Formatter`

---

### 题型一：音名与组别

**HTML 部分（放在 `#exam-content` 内）：**

```html
<div class="section">
  <div class="section-title">一、音名与组别（每空2分，共10分）</div>
  <div class="question">
    <div class="question-text">
      <span>1. 写出下列各音的音名及组别（如：c1, G, A2等）：</span>
      <span>（10分）</span>
    </div>
    <div id="vf-q1" class="staff-container"></div>
    <!-- ✅ 答题括号：position:relative 容器 + span，由 JS 精确对齐 -->
    <div id="q1-brackets" style="position:relative; height:28px; margin-top:4px;">
      <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
      <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
      <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
      <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
      <span style="font-family:'Times New Roman',serif;">( &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )</span>
    </div>
  </div>
</div>
```

**渲染代码（放在 `renderAllWithWidth` 函数内）：**

```javascript
// ✅ 含低音区 e/3（需3条加线），stave y=45，SVG 高度 175
// ⚠️ 极端低音区（如 d/2），SVG 高度需增至 260px 以上
try {
  var r = makeCtx('vf-q1', 175); // 默认 175，若含 d/2 请改 260
  var stave = new VF.Stave(10, 45, r.w - 20);
  stave.addClef('treble').setContext(r.ctx).draw();
  var notes = [
    new VF.StaveNote({ clef: 'treble', keys: ['c/4'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['a/3'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['f/5'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['b/4'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['e/3'], duration: 'w' })
  ];
  // ✅ setStrict(false) 关闭拍号验证，避免 Too many ticks 错误
  var voice = new VF.Voice({ num_beats: 5, beat_value: 1 }).setStrict(false);
  voice.addTickables(notes);
  new VF.Formatter().joinVoices([voice]).format([voice], r.w - 80);
  voice.draw(r.ctx, stave);
  alignLabels('q1-brackets', notes);
} catch(e) { console.error('vf-q1:', e); }
```

---

### 题型二：音程构建

**HTML 部分：**

```html
<div class="section">
  <div class="section-title">二、音程构建（每题2分，共10分）</div>
  <div class="question">
    <div class="question-text">
      <span>2. 以指定音为根音，向上构成指定音程：</span>
      <span>（10分）</span>
    </div>
    <div id="vf-q2" class="staff-container"></div>
    <div id="q2-labels" style="position:relative; height:28px; margin-top:4px;">
      <span>纯五度</span>
      <span>小三度</span>
      <span>大六度</span>
      <span>增四度</span>
      <span>减五度</span>
    </div>
  </div>
</div>
```

**渲染代码：**

```javascript
// ✅ 仅中音区，stave y=20，SVG 高度 130
try {
  var r = makeCtx('vf-q2', 130);
  var stave = new VF.Stave(10, 20, r.w - 20);
  stave.addClef('treble').setContext(r.ctx).draw();
  var notes = [
    new VF.StaveNote({ clef: 'treble', keys: ['c/4'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['g/4'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['d/4'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['a/4'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['e/4'], duration: 'w' })
  ];
  var voice = new VF.Voice({ num_beats: 5, beat_value: 1 }).setStrict(false);
  voice.addTickables(notes);
  new VF.Formatter().joinVoices([voice]).format([voice], r.w - 80);
  voice.draw(r.ctx, stave);
  alignLabels('q2-labels', notes);
} catch(e) { console.error('vf-q2:', e); }
```

---

### 题型三：和弦构建（低音谱号）

**HTML 部分：**

```html
<div class="section">
  <div class="section-title">三、和弦构建（每题2分，共10分）</div>
  <div class="question">
    <div class="question-text">
      <span>3. 以指定音为低音，向上构成指定和弦：</span>
      <span>（10分）</span>
    </div>
    <div id="vf-q3" class="staff-container"></div>
    <div id="q3-labels" style="position:relative; height:28px; margin-top:4px;">
      <span>大三和弦</span>
      <span>小三和弦</span>
      <span>属七和弦</span>
      <span>减七和弦</span>
      <span>大七和弦</span>
    </div>
  </div>
</div>
```

**渲染代码：**

```javascript
// ✅ 低音谱号，clef 参数必须传 'bass'
try {
  var r = makeCtx('vf-q3', 130);
  var stave = new VF.Stave(10, 20, r.w - 20);
  stave.addClef('bass').setContext(r.ctx).draw();
  var notes = [
    new VF.StaveNote({ clef: 'bass', keys: ['c/3'], duration: 'w' }),
    new VF.StaveNote({ clef: 'bass', keys: ['f/3'], duration: 'w' }),
    new VF.StaveNote({ clef: 'bass', keys: ['g/3'], duration: 'w' }),
    new VF.StaveNote({ clef: 'bass', keys: ['d/3'], duration: 'w' }),
    new VF.StaveNote({ clef: 'bass', keys: ['a/3'], duration: 'w' })
  ];
  var voice = new VF.Voice({ num_beats: 5, beat_value: 1 }).setStrict(false);
  voice.addTickables(notes);
  new VF.Formatter().joinVoices([voice]).format([voice], r.w - 80);
  voice.draw(r.ctx, stave);
  alignLabels('q3-labels', notes);
} catch(e) { console.error('vf-q3:', e); }
```

---

### 题型四：节奏组合

**HTML 部分：**

```html
<div class="section">
  <div class="section-title">四、节奏组合（共10分）</div>
  <div class="question">
    <div class="question-text">
      <span>4. 根据指定的 6/8 拍号，将下列音符进行正确的节奏组合与小节划分：</span>
      <span>（10分）</span>
    </div>
    <div id="vf-q4" class="staff-container"></div>
    <div style="border: 1px solid #ccc; height: 100px; margin-top: 10px;"></div>
  </div>
</div>
```

**渲染代码：**

```javascript
try {
  var r = makeCtx('vf-q4', 110);
  var stave = new VF.Stave(10, 10, r.w - 20);
  stave.addClef('treble').addTimeSignature('6/8').setContext(r.ctx).draw();
  var notes = [
    new VF.StaveNote({ keys: ['c/4'], duration: '8' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '8' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '8' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '4' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '8' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '4' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '8' }),
    new VF.StaveNote({ keys: ['c/4'], duration: '4' })
  ];
  var voice = new VF.Voice({ num_beats: 6, beat_value: 8 }).setStrict(false);
  voice.addTickables(notes);
  new VF.Formatter().joinVoices([voice]).format([voice], r.w - 100);
  voice.draw(r.ctx, stave);
} catch(e) { console.error('vf-q4:', e); }
```

---

### 题型五：调式音阶（空谱表）

**HTML 部分：**

```html
<div class="section">
  <div class="section-title">五、调式音阶（每题10分，共20分）</div>
  <div class="question">
    <div class="question-text">
      <span>5. 写出下列调式音阶（使用调号）：</span>
      <span>（20分）</span>
    </div>
    <div style="margin-bottom: 20px;">
      <div>(1) E自然大调音阶</div>
      <div id="vf-q5-1" class="staff-container"></div>
    </div>
    <div style="margin-bottom: 20px;">
      <div>(2) g和声小调音阶（上行与下行）</div>
      <div id="vf-q5-2" class="staff-container"></div>
    </div>
  </div>
</div>
```

**渲染代码：**

```javascript
// ✅ 空谱表（只有谱号/调号，无音符）：直接用 Stave，不经过 Voice/System
// ❌ 禁止用 system.addStave({}) 传空对象，会报 forEach undefined 错误
try {
  var r1 = makeCtx('vf-q5-1', 110);
  var stave1 = new VF.Stave(10, 10, r1.w - 20);
  stave1.addClef('treble').addKeySignature('E').setContext(r1.ctx).draw();
} catch(e) { console.error('vf-q5-1:', e); }

try {
  var r2 = makeCtx('vf-q5-2', 110);
  var stave2 = new VF.Stave(10, 10, r2.w - 20);
  stave2.addClef('treble').addKeySignature('Bb').setContext(r2.ctx).draw();
} catch(e) { console.error('vf-q5-2:', e); }
```

---

### 题型六：旋律分析

**HTML 部分：**

```html
<div class="section">
  <div class="section-title">六、旋律分析（共20分）</div>
  <div class="question">
    <div class="question-text">
      <span>6. 分析下列旋律的调式调性：</span>
      <span>（20分）</span>
    </div>
    <div id="vf-q6" class="staff-container"></div>
    <div style="margin-top: 15px;">调式调性：______________________________</div>
  </div>
</div>
```

**渲染代码：**

```javascript
try {
  var r = makeCtx('vf-q6', 130);
  var stave = new VF.Stave(10, 20, r.w - 20);
  stave.addClef('treble').addKeySignature('D').addTimeSignature('4/4').setContext(r.ctx).draw();
  var notes = [
    new VF.StaveNote({ keys: ['d/4'], duration: 'q' }),
    new VF.StaveNote({ keys: ['e/4'], duration: 'q' }),
    new VF.StaveNote({ keys: ['f/4'], duration: 'q' }),
    new VF.StaveNote({ keys: ['a/4'], duration: 'q' }),
    new VF.StaveNote({ keys: ['g/4'], duration: 'q' }),
    new VF.StaveNote({ keys: ['f/4'], duration: 'q' }),
    new VF.StaveNote({ keys: ['e/4'], duration: 'h' }),
    new VF.StaveNote({ keys: ['d/4'], duration: 'w' })
  ];
  var voice = new VF.Voice({ num_beats: 12, beat_value: 4 }).setStrict(false);
  voice.addTickables(notes);
  new VF.Formatter().joinVoices([voice]).format([voice], r.w - 120);
  voice.draw(r.ctx, stave);
} catch(e) { console.error('vf-q6:', e); }
```

---

### 题型七：解决（和弦 + 音程）

**HTML 部分：**

```html
<div class="section">
  <div class="section-title">七、解决（每题10分，共20分）</div>
  <div class="question">
    <div class="question-text">
      <span>7. 解决下列音程或和弦：</span>
      <span>（20分）</span>
    </div>
    <div style="margin-bottom: 20px;">
      <div>(1) 将 G 大调的属七和弦解决到主和弦</div>
      <div id="vf-q7-1" class="staff-container"></div>
    </div>
    <div style="margin-bottom: 20px;">
      <div>(2) 解决减五度音程（B-F）到大三度（C大调）</div>
      <div id="vf-q7-2" class="staff-container"></div>
    </div>
  </div>
</div>
```

**渲染代码：**

```javascript
// 和弦：keys 数组传多个音
try {
  var r = makeCtx('vf-q7-1', 130);
  var stave = new VF.Stave(10, 20, r.w - 20);
  stave.addClef('treble').addKeySignature('G').setContext(r.ctx).draw();
  var chord = new VF.StaveNote({ keys: ['d/4', 'f/4', 'a/4', 'c/5'], duration: 'w' });
  chord.addModifier(new VF.Accidental('#'), 1);
  VF.Formatter.FormatAndDraw(r.ctx, stave, [chord]);
} catch(e) { console.error('vf-q7-1:', e); }

// 两个音符（非和弦）：分别创建 StaveNote
try {
  var r = makeCtx('vf-q7-2', 130);
  var stave = new VF.Stave(10, 20, r.w - 20);
  stave.addClef('treble').setContext(r.ctx).draw();
  var notes = [
    new VF.StaveNote({ clef: 'treble', keys: ['b/3'], duration: 'w' }),
    new VF.StaveNote({ clef: 'treble', keys: ['f/4'], duration: 'w' })
  ];
  var voice = new VF.Voice({ num_beats: 2, beat_value: 1 }).setStrict(false);
  voice.addTickables(notes);
  new VF.Formatter().joinVoices([voice]).format([voice], r.w - 100);
  voice.draw(r.ctx, stave);
} catch(e) { console.error('vf-q7-2:', e); }
```

---

## 三、SVG 高度与谱表 y 偏移参考表

| 音区范围 | 推荐 stave y | 推荐 SVG 高度 | 说明 |
|---------|------------|------------|------|
| 仅中音区（c/4 附近） | 20 | 110 | 无加线 |
| 含高音区（到 g/5） | 30 | 130 | 上方 1 条加线 |
| 含低音区（到 e/3） | 45 | 175 | 下方 3 条加线 |
| 同时含高音和低音区 | 50 | 190 | 上下均有加线 |
| 极端音区（如 D2/B5）| 50 | 260+ | 需大量加线（如高音谱表下 D2）|
| 空谱表（无音符） | 10 | 110 | 仅谱号/调号 |

---

## 四、VexFlow 音符时值字符串对照

| 时值 | duration 字符串 |
|------|----------------|
| 全音符 | `'w'` |
| 二分音符 | `'h'` |
| 四分音符 | `'q'` |
| 八分音符 | `'8'` |
| 十六分音符 | `'16'` |
| 附点四分 | `'4d'`（需另加 `new VF.Dot()`） |

---

## 五、常见错误速查

| 错误信息 | 原因 | 修复 |
|---------|------|------|
| `Too many ticks` | EasyScore voice() 拍号验证失败 | 改用低级 API + `setStrict(false)` |
| `Cannot read properties of undefined (reading 'forEach')` | `system.addStave({})` 传空对象 | 空谱表直接用 `new VF.Stave(...)` |
| 五线谱完全空白（无报错） | Edge 跟踪防护拦截静态 CDN | 改用动态注入 `<script>` 标签 |
| 五线谱完全空白（无报错） | 内联 script 在 VexFlow 加载前执行 | 所有渲染代码集中到 body 底部 |
| 打印时五线谱被截断 | SVG 按屏幕宽度渲染，打印宽度不同 | 监听 `beforeprint`，用 621px 重渲染 |
| 音符只显示一半 | SVG 高度不足，加线超出画布 | 按音区范围增大 stave y 和 SVG 高度 |
| 答题标签与音符错位 | flex 均匀分布无法对齐不均匀音符 | 用 `getAbsoluteX()` + 绝对定位 |
