06-RENDER_SPEC.md 24 KB

简谱渲染细节规范

文档状态: ✅ 已完成
创建日期: 2026-01-29
用途: 定义简谱各元素的精确渲染规则
适用于: 简谱渲染引擎 - 绘制模块


目录

  1. 视觉尺寸标准
  2. 音符数字渲染
  3. 增时线渲染规范
  4. 减时线渲染规范
  5. 高低音点规范
  6. 附点规范
  7. 升降号规范
  8. 休止符规范
  9. 小节线规范
  10. 歌词渲染规范
  11. 布局规范

1. 视觉尺寸标准

1.1 默认配置常量

const RENDER_CONSTANTS = {
  // ===== 间距配置 =====
  /** 四分音符水平间距(像素) */
  QUARTER_NOTE_SPACING: 50,
  
  /** 小节左右内边距(像素) */
  MEASURE_PADDING: 20,
  
  /** 行间距(像素) */
  SYSTEM_SPACING: 100,
  
  /** 声部间距(像素) */
  VOICE_SPACING: 60,
  
  // ===== 字体配置 =====
  /** 音符数字字体大小(像素) */
  NOTE_FONT_SIZE: 24,
  
  /** 歌词字体大小(像素) */
  LYRIC_FONT_SIZE: 14,
  
  /** 升降号字体大小(像素) */
  ACCIDENTAL_FONT_SIZE: 12,
  
  /** 音符字体 */
  NOTE_FONT: 'Arial, "Noto Sans SC", sans-serif',
  
  /** 歌词字体 */
  LYRIC_FONT: '"Microsoft YaHei", "Noto Sans SC", sans-serif',
  
  // ===== 尺寸配置 =====
  /** 高低音点半径(像素) */
  OCTAVE_DOT_RADIUS: 2.5,
  
  /** 附点半径(像素) */
  DURATION_DOT_RADIUS: 2,
  
  /** 减时线高度/粗细(像素) */
  UNDERLINE_HEIGHT: 1.5,
  
  /** 减时线间距(像素) */
  UNDERLINE_GAP: 3,
  
  /** 增时线高度/粗细(像素) */
  EXTENSION_LINE_HEIGHT: 1.5,
  
  /** 小节线粗细(像素) */
  BARLINE_WIDTH: 1,
  
  // ===== 颜色配置 =====
  /** 音符颜色 */
  NOTE_COLOR: '#000000',
  
  /** 线条颜色 */
  LINE_COLOR: '#000000',
  
  /** 歌词颜色 */
  LYRIC_COLOR: '#333333',
};

1.2 尺寸比例关系

音符区域高度分配:
┌─────────────────────────────┐
│    升降号区域 (12px)         │  ← accidental
├─────────────────────────────┤
│    高音点区域 (8px)          │  ← octave dots (high)
├─────────────────────────────┤
│                             │
│    音符数字 (24px)           │  ← note number
│                             │
├─────────────────────────────┤
│    低音点区域 (8px)          │  ← octave dots (low)
├─────────────────────────────┤
│    减时线区域 (12px)         │  ← underlines
├─────────────────────────────┤
│    歌词区域 (20px)           │  ← lyrics
└─────────────────────────────┘

总高度约:84px(单声部无歌词约64px)

2. 音符数字渲染

2.1 基本渲染规则

interface NoteRenderSpec {
  /** 数字内容:1-7 或 0(休止符) */
  text: string;
  
  /** 字体大小 */
  fontSize: number;
  
  /** 字体粗细 */
  fontWeight: 'normal' | 'bold';
  
  /** 水平对齐 */
  textAnchor: 'middle';
  
  /** 垂直对齐基线 */
  dominantBaseline: 'central';
}

2.2 SVG实现

function drawNoteNumber(
  x: number,
  y: number,
  pitch: number,
  config: RenderConfig
): SVGTextElement {
  const text = document.createElementNS(SVG_NS, 'text');
  
  text.setAttribute('x', String(x));
  text.setAttribute('y', String(y));
  text.setAttribute('font-size', String(config.noteFontSize));
  text.setAttribute('font-family', config.noteFont);
  text.setAttribute('text-anchor', 'middle');
  text.setAttribute('dominant-baseline', 'central');
  text.setAttribute('fill', config.noteColor);
  text.setAttribute('class', 'vf-numbered-note-head');
  
  text.textContent = String(pitch);
  
  return text;
}

2.3 音符宽度计算

/**
 * 计算音符显示宽度
 * 基于时值计算,保证固定时间比例
 */
function calculateNoteWidth(realValue: number, quarterSpacing: number): number {
  return realValue * quarterSpacing;
}

// 示例:
// 四分音符(1.0) → 50px
// 八分音符(0.5) → 25px
// 二分音符(2.0) → 100px
// 全音符(4.0) → 200px

3. 增时线渲染规范

3.1 核心规则 ⭐⭐⭐

增时线是简谱渲染的核心难点!

增时线规则:
1. 只有时值 >= 1.0(四分音符及以上)才可能有增时线
2. 数量 = Math.floor(realValue) - 1
3. 每条增时线代表1个四分音符的时值
4. 增时线应该按时值均匀分布在占用的空间中

3.2 数量计算

音符类型 时值(realValue) 增时线数量 视觉表示
四分音符 1.0 0 5
附点四分 1.5 0
二分音符 2.0 1 5 —
附点二分 3.0 2 5· — —
全音符 4.0 3 5 — — —
function calcExtensionLineCount(realValue: number): number {
  if (realValue < 1.0) return 0;
  return Math.floor(realValue) - 1;
}

3.3 位置计算 ⭐⭐⭐

关键:增时线要按四分音符间距均匀分布!

interface ExtensionLinePosition {
  /** 增时线起始X坐标 */
  x: number;
  /** 增时线Y坐标(与音符数字基线平齐) */
  y: number;
  /** 增时线长度 */
  width: number;
}

/**
 * 计算增时线位置
 * @param noteX 音符中心X坐标
 * @param noteY 音符中心Y坐标
 * @param lineIndex 增时线索引(0开始)
 * @param quarterSpacing 四分音符间距
 */
function calcExtensionLinePosition(
  noteX: number,
  noteY: number,
  lineIndex: number,
  quarterSpacing: number
): ExtensionLinePosition {
  // 每条增时线占据1个四分音符的空间
  // 第1条增时线在音符后的第1个四分音符位置
  // 第2条增时线在音符后的第2个四分音符位置...
  
  const lineStartX = noteX + (lineIndex + 1) * quarterSpacing;
  const lineWidth = quarterSpacing * 0.7;  // 占空间的70%,居中
  const lineX = lineStartX - lineWidth / 2;
  
  return {
    x: lineX,
    y: noteY,
    width: lineWidth,
  };
}

3.4 视觉示意图

二分音符(2拍):
┌─────────┬─────────┐
│    5    │    —    │
│  第1拍   │  第2拍   │
└─────────┴─────────┘
  音符       增时线

全音符(4拍):
┌─────────┬─────────┬─────────┬─────────┐
│    5    │    —    │    —    │    —    │
│  第1拍   │  第2拍   │  第3拍   │  第4拍   │
└─────────┴─────────┴─────────┴─────────┘
  音符       增时线1    增时线2    增时线3

3.5 增时线样式

function drawExtensionLine(
  x: number,
  y: number,
  width: number,
  config: RenderConfig
): SVGRectElement {
  const line = document.createElementNS(SVG_NS, 'rect');
  
  line.setAttribute('x', String(x));
  line.setAttribute('y', String(y - config.extensionLineHeight / 2));
  line.setAttribute('width', String(width));
  line.setAttribute('height', String(config.extensionLineHeight));
  line.setAttribute('fill', config.lineColor);
  line.setAttribute('class', 'vf-extension-line');
  
  return line;
}

3.6 附点+增时线组合

附点二分音符(3拍):
┌───────────────┬─────────┬─────────┐
│     5·        │    —    │    —    │
│   1.5拍        │  第2拍   │  第3拍   │
└───────────────┴─────────┴─────────┘
  附点四分音符占1.5拍,后面2条增时线各占1拍

计算方式:
- realValue = 3.0
- 增时线数量 = Math.floor(3.0) - 1 = 2
- 第1条增时线位置:noteX + quarterSpacing * 1.5(从附点结束位置开始)
- 第2条增时线位置:noteX + quarterSpacing * 2.5

3.7 多声部场景

多声部时,增时线要与其他声部的音符垂直对齐:

声部1:  5 — — —    (全音符)
声部2:  1 2 3 4    (四个四分音符)
        ↑ ↑ ↑ ↑
        X坐标必须对齐

规则:先进行多声部对齐计算,再绘制增时线

4. 减时线渲染规范

4.1 数量计算

音符类型 时值(realValue) 减时线数量 视觉表示
四分音符 1.0 0 5
八分音符 0.5 1 5 + 1条线
十六分音符 0.25 2 5 + 2条线
三十二分音符 0.125 3 5 + 3条线
function calcUnderlineCount(realValue: number): number {
  if (realValue >= 1.0) return 0;
  return Math.round(Math.log2(1 / realValue));
}

4.2 位置和间距

interface UnderlineSpec {
  /** 减时线宽度 */
  width: number;
  /** 减时线高度(粗细) */
  height: number;
  /** 第一条线距离音符底部的距离 */
  topOffset: number;
  /** 多条线之间的间距 */
  gap: number;
}

const UNDERLINE_SPEC: UnderlineSpec = {
  width: 16,      // 略宽于数字宽度
  height: 1.5,    // 线条粗细
  topOffset: 4,   // 距音符底部4px
  gap: 3,         // 多条线间距3px
};

4.3 视觉示意图

八分音符(1条减时线):
    5
   ───

十六分音符(2条减时线):
    5
   ───
   ───

三十二分音符(3条减时线):
    5
   ───
   ───
   ───

4.4 SVG实现

function drawUnderlines(
  noteX: number,
  noteBottomY: number,
  count: number,
  config: RenderConfig
): SVGGElement {
  const group = document.createElementNS(SVG_NS, 'g');
  group.setAttribute('class', 'vf-underlines');
  
  const width = UNDERLINE_SPEC.width;
  const startX = noteX - width / 2;
  
  for (let i = 0; i < count; i++) {
    const y = noteBottomY + UNDERLINE_SPEC.topOffset + 
              i * (UNDERLINE_SPEC.height + UNDERLINE_SPEC.gap);
    
    const line = document.createElementNS(SVG_NS, 'rect');
    line.setAttribute('x', String(startX));
    line.setAttribute('y', String(y));
    line.setAttribute('width', String(width));
    line.setAttribute('height', String(UNDERLINE_SPEC.height));
    line.setAttribute('fill', config.lineColor);
    line.setAttribute('class', 'vf-underline');
    
    group.appendChild(line);
  }
  
  return group;
}

4.5 连续音符的减时线连接

连续的相同时值音符,减时线应该连接:

分离绘制:          连接绘制(推荐):
  1   2   3   4       1   2   3   4
  _   _   _   _       ─────────────

规则:
- 同一拍内的连续短音符,减时线连接
- 跨拍的音符,减时线可以分开
- 当前实现先使用分离绘制,后续优化为连接绘制

5. 高低音点规范

5.1 位置规则

高音点:在音符数字上方
低音点:在音符数字下方

高两个八度:  ··
              5

高一个八度:   ·
              5

中音:        5

低一个八度:   5
              ·

低两个八度:   5
              ··

5.2 尺寸和间距

interface OctaveDotSpec {
  /** 点的半径 */
  radius: number;
  /** 第一个点距离数字的距离 */
  offset: number;
  /** 多个点之间的间距 */
  gap: number;
}

const OCTAVE_DOT_SPEC: OctaveDotSpec = {
  radius: 2.5,
  offset: 6,   // 距离数字6px
  gap: 5,      // 点间距5px
};

5.3 SVG实现

function drawOctaveDots(
  noteX: number,
  noteY: number,
  octaveOffset: number,
  config: RenderConfig
): SVGGElement {
  const group = document.createElementNS(SVG_NS, 'g');
  group.setAttribute('class', 'vf-octave-dots');
  
  const count = Math.abs(octaveOffset);
  const isHigh = octaveOffset > 0;
  
  // 高音点在上方,低音点在下方
  const baseY = isHigh 
    ? noteY - config.noteFontSize / 2 - OCTAVE_DOT_SPEC.offset
    : noteY + config.noteFontSize / 2 + OCTAVE_DOT_SPEC.offset;
  
  for (let i = 0; i < count; i++) {
    const dotY = isHigh
      ? baseY - i * OCTAVE_DOT_SPEC.gap
      : baseY + i * OCTAVE_DOT_SPEC.gap;
    
    const dot = document.createElementNS(SVG_NS, 'circle');
    dot.setAttribute('cx', String(noteX));
    dot.setAttribute('cy', String(dotY));
    dot.setAttribute('r', String(OCTAVE_DOT_SPEC.radius));
    dot.setAttribute('fill', config.noteColor);
    dot.setAttribute('class', isHigh ? 'vf-high-dot' : 'vf-low-dot');
    
    group.appendChild(dot);
  }
  
  return group;
}

6. 附点规范

6.1 位置规则

附点位置:在音符数字的右侧,垂直居中

单附点:   5·

双附点:   5··

三附点:   5···(极少见)

6.2 尺寸和间距

interface DurationDotSpec {
  /** 点的半径 */
  radius: number;
  /** 第一个点距离数字右边的距离 */
  offset: number;
  /** 多个点之间的间距 */
  gap: number;
}

const DURATION_DOT_SPEC: DurationDotSpec = {
  radius: 2,
  offset: 4,   // 距离数字右边4px
  gap: 4,      // 点间距4px
};

6.3 SVG实现

function drawDurationDots(
  noteX: number,
  noteY: number,
  dotCount: number,
  config: RenderConfig
): SVGGElement {
  const group = document.createElementNS(SVG_NS, 'g');
  group.setAttribute('class', 'vf-duration-dots');
  
  // 数字宽度约为字体大小的0.6
  const digitWidth = config.noteFontSize * 0.6;
  const startX = noteX + digitWidth / 2 + DURATION_DOT_SPEC.offset;
  
  for (let i = 0; i < dotCount; i++) {
    const dotX = startX + i * (DURATION_DOT_SPEC.radius * 2 + DURATION_DOT_SPEC.gap);
    
    const dot = document.createElementNS(SVG_NS, 'circle');
    dot.setAttribute('cx', String(dotX));
    dot.setAttribute('cy', String(noteY));
    dot.setAttribute('r', String(DURATION_DOT_SPEC.radius));
    dot.setAttribute('fill', config.noteColor);
    dot.setAttribute('class', 'vf-duration-dot');
    
    group.appendChild(dot);
  }
  
  return group;
}

7. 升降号规范

7.1 位置规则

升降号位置:在音符数字的左上方

升号:    #
          5

降号:    ♭
          5

还原号:   ♮
          5

7.2 符号字符

类型 Unicode 显示
升号 #\u266F # 或 ♯
降号 \u266D
还原号 \u266E
重升 \u00D7## × 或 ##
重降 \u266D\u266D ♭♭

7.3 尺寸和间距

interface AccidentalSpec {
  /** 字体大小(相对于音符字体) */
  fontSizeRatio: number;
  /** 水平偏移(向左) */
  offsetX: number;
  /** 垂直偏移(向上) */
  offsetY: number;
}

const ACCIDENTAL_SPEC: AccidentalSpec = {
  fontSizeRatio: 0.5,  // 音符字体的50%
  offsetX: 8,          // 向左偏移8px
  offsetY: 8,          // 向上偏移8px
};

7.4 SVG实现

const ACCIDENTAL_SYMBOLS: Record<string, string> = {
  'sharp': '#',
  'flat': '♭',
  'natural': '♮',
  'double-sharp': '×',
  'double-flat': '♭♭',
};

function drawAccidental(
  noteX: number,
  noteY: number,
  accidental: string,
  config: RenderConfig
): SVGTextElement {
  const text = document.createElementNS(SVG_NS, 'text');
  
  const fontSize = config.noteFontSize * ACCIDENTAL_SPEC.fontSizeRatio;
  const x = noteX - config.noteFontSize * 0.3 - ACCIDENTAL_SPEC.offsetX;
  const y = noteY - config.noteFontSize * 0.3 - ACCIDENTAL_SPEC.offsetY;
  
  text.setAttribute('x', String(x));
  text.setAttribute('y', String(y));
  text.setAttribute('font-size', String(fontSize));
  text.setAttribute('font-family', config.noteFont);
  text.setAttribute('text-anchor', 'middle');
  text.setAttribute('fill', config.noteColor);
  text.setAttribute('class', 'vf-accidental');
  
  text.textContent = ACCIDENTAL_SYMBOLS[accidental] || accidental;
  
  return text;
}

8. 休止符规范

8.1 渲染规则

休止符用数字"0"表示,位置与普通音符相同

四分休止符:    0
八分休止符:    0
               ─
二分休止符:    0 —(不画增时线,用空白表示)
全休止符:      0 — — —(不画增时线,用空白表示)

8.2 特殊处理

function renderRest(
  x: number,
  y: number,
  realValue: number,
  config: RenderConfig
): SVGGElement {
  const group = document.createElementNS(SVG_NS, 'g');
  group.setAttribute('class', 'vf-rest');
  
  // 绘制数字"0"
  const text = drawNoteNumber(x, y, 0, config);
  group.appendChild(text);
  
  // 休止符的减时线正常绘制
  if (realValue < 1.0) {
    const underlineCount = calcUnderlineCount(realValue);
    const underlines = drawUnderlines(x, y + config.noteFontSize / 2, underlineCount, config);
    group.appendChild(underlines);
  }
  
  // 休止符不绘制增时线!
  // 长休止符通过占据空间来表示
  
  return group;
}

9. 小节线规范

9.1 类型

类型 样式 说明
single 普通单线
double 双线(段落结束)
repeat-start ║: 反复开始
repeat-end :║ 反复结束
final ║█ 终止线

9.2 尺寸

interface BarlineSpec {
  /** 单线粗细 */
  thinWidth: number;
  /** 粗线粗细 */
  thickWidth: number;
  /** 双线间距 */
  doubleGap: number;
  /** 反复点半径 */
  repeatDotRadius: number;
  /** 反复点间距 */
  repeatDotGap: number;
}

const BARLINE_SPEC: BarlineSpec = {
  thinWidth: 1,
  thickWidth: 3,
  doubleGap: 3,
  repeatDotRadius: 2,
  repeatDotGap: 8,
};

9.3 SVG实现

function drawBarline(
  x: number,
  topY: number,
  bottomY: number,
  type: string,
  config: RenderConfig
): SVGGElement {
  const group = document.createElementNS(SVG_NS, 'g');
  group.setAttribute('class', `vf-barline vf-barline-${type}`);
  
  const height = bottomY - topY;
  
  switch (type) {
    case 'single':
      group.appendChild(createLine(x, topY, x, bottomY, BARLINE_SPEC.thinWidth));
      break;
      
    case 'double':
      group.appendChild(createLine(x - BARLINE_SPEC.doubleGap, topY, 
                                   x - BARLINE_SPEC.doubleGap, bottomY, 
                                   BARLINE_SPEC.thinWidth));
      group.appendChild(createLine(x, topY, x, bottomY, BARLINE_SPEC.thinWidth));
      break;
      
    case 'final':
      group.appendChild(createLine(x - BARLINE_SPEC.doubleGap - BARLINE_SPEC.thickWidth, 
                                   topY, 
                                   x - BARLINE_SPEC.doubleGap - BARLINE_SPEC.thickWidth, 
                                   bottomY, 
                                   BARLINE_SPEC.thinWidth));
      group.appendChild(createRect(x - BARLINE_SPEC.thickWidth, topY, 
                                   BARLINE_SPEC.thickWidth, height));
      break;
      
    // ... 其他类型
  }
  
  return group;
}

10. 歌词渲染规范

10.1 位置规则

歌词位置:在音符下方,与音符水平对齐

单行歌词:
    5     3     2     1
   小    星    星    亮

多遍歌词(垂直排列):
    5     3     2     1
   小    星    星    亮    ← 第1遍
   一    闪    一    闪    ← 第2遍

10.2 尺寸和间距

interface LyricSpec {
  /** 字体大小 */
  fontSize: number;
  /** 距离音符底部的距离 */
  topOffset: number;
  /** 多遍歌词行间距 */
  lineHeight: number;
}

const LYRIC_SPEC: LyricSpec = {
  fontSize: 14,
  topOffset: 25,    // 距离音符底部25px
  lineHeight: 18,   // 行间距18px
};

10.3 DOM结构要求

<!-- 歌词元素必须包含以下属性,用于业务层高亮匹配 -->
<text 
  class="vf-lyric lyric{noteId}"
  lyricIndex="1"
  data-note-id="{noteId}"
>
  歌词文字
</text>

10.4 SVG实现

function drawLyric(
  noteX: number,
  noteBottomY: number,
  text: string,
  lyricIndex: number,
  noteId: string,
  config: RenderConfig
): SVGTextElement {
  const textEl = document.createElementNS(SVG_NS, 'text');
  
  const y = noteBottomY + LYRIC_SPEC.topOffset + 
            (lyricIndex - 1) * LYRIC_SPEC.lineHeight;
  
  textEl.setAttribute('x', String(noteX));
  textEl.setAttribute('y', String(y));
  textEl.setAttribute('font-size', String(LYRIC_SPEC.fontSize));
  textEl.setAttribute('font-family', config.lyricFont);
  textEl.setAttribute('text-anchor', 'middle');
  textEl.setAttribute('fill', config.lyricColor);
  textEl.setAttribute('class', `vf-lyric lyric${noteId}`);
  textEl.setAttribute('lyricIndex', String(lyricIndex));
  textEl.setAttribute('data-note-id', noteId);
  
  textEl.textContent = text;
  
  return textEl;
}

11. 布局规范

11.1 小节宽度计算

/**
 * 计算小节宽度(固定时间比例)
 * 公式:(拍数 / 单位拍 × 4) × 四分音符间距 + 左右padding
 */
function calculateMeasureWidth(
  beats: number,
  beatType: number,
  quarterSpacing: number,
  padding: number
): number {
  // 小节总时值(以四分音符为单位)
  const totalBeats = beats / (beatType / 4);
  
  // 内容宽度
  const contentWidth = totalBeats * quarterSpacing;
  
  // 总宽度
  return contentWidth + padding * 2;
}

// 示例:
// 4/4拍: (4 / 1) × 50 + 20 × 2 = 240px
// 3/4拍: (3 / 1) × 50 + 20 × 2 = 190px
// 6/8拍: (6 / 2) × 50 + 20 × 2 = 190px

11.2 音符X坐标计算

/**
 * 计算音符X坐标(固定时间比例)
 * 公式:小节X + padding + 时间戳 × 四分音符间距
 */
function calculateNoteX(
  measureX: number,
  timestamp: number,
  padding: number,
  quarterSpacing: number
): number {
  return measureX + padding + timestamp * quarterSpacing;
}

11.3 自动换行规则

interface LineBreakSpec {
  /** 最大行宽 */
  maxWidth: number;
  /** 最小小节数/行 */
  minMeasuresPerLine: number;
  /** 最大小节数/行 */
  maxMeasuresPerLine: number;
}

function calculateLineBreaks(
  measures: JianpuMeasure[],
  maxWidth: number
): number[][] {
  const lines: number[][] = [];
  let currentLine: number[] = [];
  let currentWidth = 0;
  
  measures.forEach((measure, index) => {
    if (currentWidth + measure.width > maxWidth && currentLine.length > 0) {
      lines.push(currentLine);
      currentLine = [];
      currentWidth = 0;
    }
    
    currentLine.push(index);
    currentWidth += measure.width;
  });
  
  if (currentLine.length > 0) {
    lines.push(currentLine);
  }
  
  return lines;
}

11.4 多声部Y坐标计算

/**
 * 计算声部Y坐标
 */
function calculateVoiceY(
  baseY: number,
  voiceIndex: number,
  voiceCount: number,
  voiceSpacing: number
): number {
  // 多声部垂直居中分布
  const totalHeight = (voiceCount - 1) * voiceSpacing;
  const startY = baseY - totalHeight / 2;
  
  return startY + voiceIndex * voiceSpacing;
}

验收标准检查

  • 所有渲染元素都有精确的位置和尺寸定义
  • 包含视觉示意图(ASCII图)
  • 包含特殊情况的处理规则
  • 包含完整的SVG实现代码模板
  • 包含增时线的详细布局算法

文档版本: v1.0
最后更新: 2026-01-29
维护者: 简谱渲染引擎开发团队