演示文稿无障碍设计指南

对比度、替代文本与结构化标记的工程实现


1. 为什么演示文稿需要无障碍设计

无障碍设计不仅关乎合规(如 WCAG 2.1、Section 508、残疾人保障法),更关乎覆盖面。全球约 15% 的人口有某种形式的残障,包括视力障碍、色觉缺陷、听力障碍、运动障碍和认知障碍。一份无法被屏幕阅读器解析的 PPT,等于直接排斥了这部分受众。

无障碍的四个维度:
+--------------+     +--------------+
| 可感知       |     | 可操作       |
| (Perceivable)|     | (Operable)   |
| 看得到/听得到|     | 能用键盘操作 |
+--------------+     +--------------+

+--------------+     +--------------+
| 可理解       |     | 健壮性       |
|(Understandable)    | (Robust)     |
| 内容易懂     |     | 兼容辅助技术 |
+--------------+     +--------------+

1.1 演示文稿的特殊挑战

挑战 说明 影响人群
视觉依赖 PPT 高度依赖视觉传达 视力障碍、色盲
时间限制 演示按固定节奏推进 认知障碍、阅读困难
多模态 文字+图片+图表+动画混合 多种障碍类型
格式多样 PPTX/PDF/HTML 各有无障碍短板 辅助技术用户
线上/线下 现场投影 vs 远程共享 vs 异步阅读 听力障碍(远程)

2. 对比度标准

2.1 WCAG 对比度要求

级别 标准文字 (>= 14pt) 大文字 (>= 18pt 或 14pt 粗)
AA >= 4.5:1 >= 3:1
AAA >= 7:1 >= 4.5:1

演示文稿建议至少达到 AA 级别,标题文字(通常 >= 18pt)需 3:1,正文需 4.5:1。

2.2 常见问题对比度

问题组合 (对比度不足):
  浅灰文字 #9CA3AF 在白色背景 #FFFFFF 上 --> 2.6:1 (不通过)
  白色文字 #FFFFFF 在浅蓝背景 #93C5FD 上 --> 2.2:1 (不通过)
  黄色文字 #FCD34D 在白色背景 #FFFFFF 上 --> 1.6:1 (不通过)

安全组合 (对比度充足):
  深灰文字 #374151 在白色背景 #FFFFFF 上 --> 10.9:1 (AAA)
  白色文字 #FFFFFF 在深蓝背景 #1E40AF 上 --> 8.6:1 (AAA)
  深灰文字 #1F2937 在浅灰背景 #F3F4F6 上 --> 14.7:1 (AAA)

2.3 对比度自动检查

function luminance(r, g, b) {
  const [rs, gs, bs] = [r, g, b].map(c => {
    c = c / 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

function contrastRatio(color1, color2) {
  const l1 = luminance(...hexToRgb(color1));
  const l2 = luminance(...hexToRgb(color2));
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

function checkSlideContrast(slide) {
  const issues = [];

  for (const element of slide.elements) {
    if (element.type === 'text') {
      const textColor = element.color;
      const bgColor = getBackgroundColorAt(slide, element.position);
      const ratio = contrastRatio(textColor, bgColor);

      const isLargeText = element.fontSize >= 18 ||
                          (element.fontSize >= 14 && element.bold);
      const threshold = isLargeText ? 3.0 : 4.5;

      if (ratio < threshold) {
        issues.push({
          element: element.id,
          type: 'insufficient_contrast',
          actual: ratio.toFixed(2),
          required: threshold,
          textColor,
          bgColor,
          fix: suggestBetterColor(textColor, bgColor, threshold)
        });
      }
    }
  }

  return issues;
}

function suggestBetterColor(textColor, bgColor, targetRatio) {
  // 逐步加深/加浅文字颜色直到满足对比度
  let [r, g, b] = hexToRgb(textColor);
  const bgLum = luminance(...hexToRgb(bgColor));

  for (let i = 0; i < 100; i++) {
    const textLum = luminance(r, g, b);
    const ratio = (Math.max(textLum, bgLum) + 0.05) /
                  (Math.min(textLum, bgLum) + 0.05);

    if (ratio >= targetRatio) {
      return rgbToHex(r, g, b);
    }

    // 如果文字比背景亮,加深;否则加亮
    if (textLum > bgLum) {
      r = Math.max(0, r - 5);
      g = Math.max(0, g - 5);
      b = Math.max(0, b - 5);
    } else {
      r = Math.min(255, r + 5);
      g = Math.min(255, g + 5);
      b = Math.min(255, b + 5);
    }
  }

  return '#000000';  // 回退到黑色
}

3. 替代文本(Alt Text)

3.1 替代文本原则

元素类型 Alt Text 策略 示例
装饰图片 alt="" (空字符串) 背景纹理、装饰线条
信息图片 描述图片传达的信息 "公司新办公楼外景,位于成都高新区"
图表 描述数据结论 + 提供数据表 "柱状图显示Q4营收同比增长45%"
图标 描述图标含义 "邮件图标"
Logo 品牌名称 "合规网 Logo"
截图 描述截图内容 "产品仪表盘截图,显示实时用户数据"

3.2 图表替代文本生成

def generate_chart_alt_text(chart_data: ChartData) -> str:
    """为图表自动生成结构化替代文本"""

    parts = []

    # 1. 图表类型与标题
    parts.append(f"{chart_data.chart_type}图: {chart_data.title}")

    # 2. 核心结论(最重要的信息)
    if chart_data.key_insight:
        parts.append(chart_data.key_insight)

    # 3. 数据摘要
    if chart_data.chart_type in ('bar', 'column'):
        max_item = max(chart_data.series, key=lambda x: x.value)
        min_item = min(chart_data.series, key=lambda x: x.value)
        parts.append(
            f"最高: {max_item.label} ({max_item.value}),"
            f"最低: {min_item.label} ({min_item.value})"
        )

    elif chart_data.chart_type == 'line':
        first = chart_data.series[0]
        last = chart_data.series[-1]
        trend = "上升" if last.value > first.value else "下降"
        parts.append(
            f"趋势{trend},从 {first.value} 到 {last.value}"
        )

    elif chart_data.chart_type in ('pie', 'donut'):
        top3 = sorted(chart_data.series, key=lambda x: x.value, reverse=True)[:3]
        desc = ",".join(f"{item.label} {item.value}%" for item in top3)
        parts.append(f"主要组成: {desc}")

    # 4. 完整数据表(供屏幕阅读器用户深入了解)
    parts.append("详细数据: " + ",".join(
        f"{item.label}: {item.value}" for item in chart_data.series
    ))

    return "。".join(parts) + "。"

3.3 PPTX 中设置 Alt Text

// PptxGenJS 设置替代文本
slide.addImage({
  path: 'chart.png',
  x: 1, y: 1, w: 8, h: 5,
  altText: '柱状图显示2025年四季度营收趋势。Q1 2300万,Q2 2800万,Q3 3200万,Q4 4500万。全年增长率45%。'
});

// 装饰图片设置为 presentational
slide.addImage({
  path: 'decorative_bg.png',
  x: 0, y: 0, w: 13.33, h: 7.5,
  altText: ''  // 空 = 装饰性
});

4. 结构化标记

4.1 阅读顺序

屏幕阅读器按照元素的阅读顺序遍历内容。如果阅读顺序错误,用户接收到的信息就是混乱的。

视觉布局:                    错误阅读顺序:
+---------+---------+       1. 左栏标题
| 标题    | 标题    |       2. 右栏标题
+---------+---------+       3. 左栏内容
| 左栏    | 右栏    |       4. 右栏内容
| 内容    | 内容    |       (标题/内容交叉,逻辑混乱)
+---------+---------+

正确阅读顺序:
1. 页面标题
2. 左栏标题
3. 左栏内容
4. 右栏标题
5. 右栏内容

4.2 HTML 语义化标记

<article class="slide" role="region" aria-label="第 5 页,共 20 页">
  <h2 class="slide-title">用户增长分析</h2>

  <div class="slide-content" role="group" aria-label="双栏内容">
    <section aria-label="增长数据">
      <h3>关键指标</h3>
      <dl class="metrics">
        <div class="metric-item">
          <dt>月活用户</dt>
          <dd>120 万</dd>
        </div>
        <div class="metric-item">
          <dt>日活用户</dt>
          <dd>45 万</dd>
        </div>
      </dl>
    </section>

    <section aria-label="趋势图表">
      <h3>增长趋势</h3>
      <figure role="img" aria-label="折线图显示用户量从1月50万增长到12月120万">
        <div id="chart-container"></div>
        <figcaption class="sr-only">
          月度用户增长数据:1月50万,2月55万,3月62万,
          4月70万,5月78万,6月85万,7月110万(新功能上线),
          8月105万,9月108万,10月112万,11月115万,12月120万。
        </figcaption>
      </figure>
    </section>
  </div>

  <aside class="slide-notes" aria-label="演讲者备注">
    <p>强调7月的用户激增与新功能上线的关联。</p>
  </aside>
</article>

4.3 PPTX 结构标记

from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.enum.text import PP_ALIGN

def create_accessible_slide(prs, content):
    slide = prs.slides.add_slide(prs.slide_layouts[1])

    # 设置标题(屏幕阅读器优先读取)
    title = slide.shapes.title
    title.text = content['title']

    # 设置阅读顺序
    # PPTX 中阅读顺序由 shapes 在 XML 中的排列决定
    # 将最重要的元素放在最前面

    # 为每个形状设置 alt text
    for shape in slide.shapes:
        if hasattr(shape, 'alt_text'):
            if shape.shape_id in content.get('decorative', []):
                shape.alt_text = ''  # 装饰元素
            else:
                shape.alt_text = content.get('alt_texts', {}).get(
                    shape.shape_id, shape.text or ''
                )

    return slide

5. 色彩无障碍

5.1 色盲安全配色

约 8% 的男性和 0.5% 的女性有某种程度的色觉缺陷。最常见的是红绿色盲。

标准配色:                  色盲安全替代:
红 #EF4444  绿 #22C55E     蓝 #2563EB  橙 #F59E0B
(红绿色盲无法区分)          (明度差异大,可区分)

额外区分手段:
- 图案/纹理: 条纹 vs 点状 vs 实心
- 形状: 圆形 vs 方形 vs 三角形
- 标签: 直接标注数值
- 线型: 实线 vs 虚线 vs 点线

5.2 色盲模拟检查

// 色盲模拟矩阵(基于 Brettel 算法)
const colorBlindMatrices = {
  protanopia: [   // 红色盲
    [0.567, 0.433, 0.000],
    [0.558, 0.442, 0.000],
    [0.000, 0.242, 0.758]
  ],
  deuteranopia: [ // 绿色盲
    [0.625, 0.375, 0.000],
    [0.700, 0.300, 0.000],
    [0.000, 0.300, 0.700]
  ],
  tritanopia: [   // 蓝色盲
    [0.950, 0.050, 0.000],
    [0.000, 0.433, 0.567],
    [0.000, 0.475, 0.525]
  ]
};

function simulateColorBlind(hex, type) {
  const [r, g, b] = hexToRgb(hex).map(v => v / 255);
  const matrix = colorBlindMatrices[type];

  const simR = matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b;
  const simG = matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b;
  const simB = matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b;

  return rgbToHex(
    Math.round(simR * 255),
    Math.round(simG * 255),
    Math.round(simB * 255)
  );
}

function checkColorBlindSafety(palette) {
  const types = ['protanopia', 'deuteranopia', 'tritanopia'];
  const issues = [];

  for (const type of types) {
    const simulated = palette.map(c => simulateColorBlind(c, type));

    // 检查模拟后的颜色之间是否仍可区分
    for (let i = 0; i < simulated.length; i++) {
      for (let j = i + 1; j < simulated.length; j++) {
        const diff = colorDifference(simulated[i], simulated[j]);
        if (diff < 30) {  // CIEDE2000 差异阈值
          issues.push({
            type,
            color1: palette[i],
            color2: palette[j],
            difference: diff,
            suggestion: '添加图案或标签以区分这两种颜色'
          });
        }
      }
    }
  }

  return issues;
}

6. 键盘导航

6.1 HTML 演示的键盘支持

按键 功能 ARIA 要求
Tab 切换到下一个可聚焦元素 焦点可见
Shift+Tab 切换到上一个可聚焦元素 焦点可见
右箭头 / 空格 下一页 aria-live 通知
左箭头 上一页 aria-live 通知
Home 第一页 --
End 最后一页 --
Escape 退出全屏 --
F 进入全屏 --
// 焦点管理与 ARIA 通知
function goToSlide(index) {
  const slide = slides[index];
  slide.classList.add('active');

  // ARIA 实时通知
  const announcer = document.getElementById('aria-announcer');
  announcer.textContent = `第 ${index + 1} 页,共 ${total} 页。${slide.getAttribute('aria-label') || ''}`;

  // 焦点移到新页面的第一个可聚焦元素
  const firstFocusable = slide.querySelector(
    'a, button, input, [tabindex="0"]'
  ) || slide;
  firstFocusable.focus();
}

// ARIA 实时区域
// <div id="aria-announcer" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>

6.2 焦点样式

/* 焦点指示器:必须可见且高对比度 */
.slide *:focus-visible {
  outline: 3px solid #2563EB;
  outline-offset: 2px;
  border-radius: 2px;
}

/* 不要这样做: */
/* *:focus { outline: none; }  <-- 删除焦点指示器 = 无障碍灾难 */

7. 动画与运动

7.1 减少运动偏好

部分用户对动画敏感(前庭功能障碍),操作系统提供了"减少运动"设置:

/* 尊重系统偏好 */
@media (prefers-reduced-motion: reduce) {
  .slide-transition {
    transition: none;
  }

  .slide-element {
    animation: none;
  }

  .chart-animation {
    animation-duration: 0.01ms;  /* 瞬间完成,保留最终状态 */
  }
}
// JavaScript 中检测
const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (prefersReducedMotion) {
  // 禁用所有非必要动画
  // 图表直接显示最终状态
  chartOptions.animation = false;
}

7.2 安全动画规则

规则 说明
禁止频闪 每秒闪烁不超过 3 次
避免大面积运动 全屏旋转/缩放可能引发眩晕
提供暂停控制 自动播放的动画必须可暂停
淡入优于滑入 淡入/淡出对运动敏感用户更友好
持续时间合理 单个动画 200-500ms,不要过长

8. 文字可读性

8.1 最小字号

使用场景 最小字号 推荐字号
投影(大屏) 18px 24-48px
屏幕共享 16px 20-36px
打印 12px 14-24px
手机阅读 14px 16-20px

8.2 行间距与字间距

.slide-body {
  line-height: 1.5;        /* 至少 1.5 倍 */
  letter-spacing: 0.02em;  /* 中文微调字间距 */
  word-spacing: 0.05em;    /* 英文词间距 */
  max-width: 70ch;         /* 每行最多 70 字符 */
}

/* 阅读障碍友好设置 */
.dyslexia-friendly {
  font-family: 'OpenDyslexic', 'Source Han Sans CN', sans-serif;
  line-height: 1.8;
  letter-spacing: 0.05em;
  word-spacing: 0.12em;
}

8.3 语言标记

<!-- 主语言声明 -->
<html lang="zh-CN">

<!-- 混合语言段落 -->
<p>
  本季度我们使用了
  <span lang="en">Machine Learning</span>
  技术来优化推荐算法。
</p>

9. 无障碍检查清单

9.1 自动化检查

function accessibilityAudit(presentation) {
  const issues = [];

  for (const slide of presentation.slides) {
    // 1. 对比度检查
    issues.push(...checkSlideContrast(slide));

    // 2. Alt Text 检查
    for (const img of slide.images) {
      if (img.altText === undefined || img.altText === null) {
        issues.push({
          slide: slide.index,
          element: img.id,
          type: 'missing_alt_text',
          severity: 'error',
          message: '图片缺少替代文本'
        });
      }
    }

    // 3. 阅读顺序检查
    if (!slide.readingOrderDefined) {
      issues.push({
        slide: slide.index,
        type: 'undefined_reading_order',
        severity: 'warning',
        message: '未定义阅读顺序'
      });
    }

    // 4. 最小字号检查
    for (const text of slide.textElements) {
      if (text.fontSize < 14) {
        issues.push({
          slide: slide.index,
          element: text.id,
          type: 'small_font',
          severity: 'warning',
          message: `字号 ${text.fontSize}px 可能不够清晰`
        });
      }
    }

    // 5. 颜色依赖检查
    issues.push(...checkColorDependency(slide));

    // 6. 标题层级检查
    issues.push(...checkHeadingHierarchy(slide));
  }

  return {
    issues,
    score: calculateA11yScore(issues),
    summary: {
      errors: issues.filter(i => i.severity === 'error').length,
      warnings: issues.filter(i => i.severity === 'warning').length,
      info: issues.filter(i => i.severity === 'info').length,
    }
  };
}

9.2 人工检查清单

检查项 验证方法 通过标准
对比度 自动工具检查 所有文字达到 AA
替代文本 逐图检查 每个信息图都有描述
阅读顺序 Tab 键遍历 逻辑顺序正确
键盘导航 纯键盘操作 所有功能可达
色盲安全 灰度模式查看 信息不依赖颜色
动画安全 开启减少运动 核心内容不受影响
字号可读 投影测试 最后排可读
语言标记 屏幕阅读器测试 正确朗读

10. 工具与资源

工具 用途 平台
axe DevTools 自动化无障碍检查 浏览器扩展
WAVE Web 无障碍评估 在线工具
Color Oracle 色盲模拟 桌面应用
Contrast Checker 对比度计算 webaim.org
NVDA 屏幕阅读器测试 Windows
VoiceOver 屏幕阅读器测试 macOS/iOS
PowerPoint 辅助功能检查器 PPTX 无障碍检查 Office 内置
PAC 3 PDF 无障碍检查 桌面应用

Maurice | maurice_wen@proton.me