Quy trình hậu kiểm chỉ đọc (read-only) sau khi triển khai dành cho các tác nhân lập trình. Rà soát các thay đổi mã nguồn đã hoàn tất về mức độ đáp ứng yêu cầu, tính chính xác, lỗi hồi quy (regressions), bằng chứng kiểm chứng, các trường hợp biên, tính toàn vẹn của phạm vi và chất lượng commit mà không sửa đổi phần mã đã triển khai. Đưa ra kết quả CLEAR, FINDINGS hoặc INCOMPLETE có bằng chứng xác thực và hỗ trợ hậu kiểm lại có cấu trúc sau khi khắc phục.
--- name: post-implementation-audit description: Hậu kiểm ở chế độ chỉ đọc đối với các thay đổi mã nguồn đã hoàn tất. Sử dụng sau khi triển khai hoặc khắc phục lỗi để xác minh mức độ đáp ứng yêu cầu, tính chính xác, lỗi hồi quy, bước kiểm chứng phù hợp và phạm vi commit. Không sử dụng kỹ năng này để triển khai hoặc sửa lỗi. --- # Hậu kiểm Sau Triển khai (Post-Implementation Audit) Thực hiện hậu kiểm độc lập các thay đổi mã nguồn đã hoàn tất bằng cách sử dụng các bằng chứng hiện có. Quy trình này ở chế độ chỉ đọc (read-only). Hãy tìm ra các vấn đề thực sự có ý nghĩa khi chúng tồn tại; không tự bịa ra hoặc thổi phồng các phát hiện lỗi. ## Phạm vi và ranh giới Tuân thủ nhiệm vụ hiện tại, các hướng dẫn áp dụng trong kho mã nguồn như `AGENTS.md`, và ngữ cảnh triển khai thực tế. Không mở rộng phạm vi hậu kiểm chỉ vì thấy có thể rà soát thêm. Không triển khai, khắc phục, tái cấu trúc, đưa vào vùng chờ (stage), commit hoặc chủ đích sửa đổi trạng thái kho mã nguồn trong khi quy trình này đang hoạt động. Nếu một bước kiểm chứng bắt buộc sẽ chủ đích làm thay đổi các tệp đang được theo dõi (tracked files), đừng chạy bước đó trong quá trình hậu kiểm. Hãy báo cáo đó là một hạn chế về kiểm chứng và trả phần việc đó về giai đoạn triển khai/khắc phục. Các tác dụng phụ ngoài dự kiến phát sinh từ những lệnh kiểm chứng hợp lệ phải được báo cáo lại, chứ không được tự ý hoàn tác hay dọn dẹp. Không tạo tệp báo cáo hậu kiểm riêng trừ khi được yêu cầu rõ ràng. ## 1. Thiết lập mục tiêu và mốc cơ sở (baseline) Xác định rõ thay đổi nào thực sự đang được hậu kiểm trước khi đưa ra đánh giá. Khi có sẵn Git, hãy ưu tiên chọn mốc cơ sở theo thứ tự sau: 1. mốc cơ sở hoặc dải commit được chỉ định tường minh từ nhiệm vụ; 2. điểm bắt đầu triển khai đã biết được củng cố bởi ngữ cảnh; 3. các thay đổi trong vùng chờ (staged) hoặc cây thư mục làm việc (working-tree) có thể quy thuộc rõ ràng. Không bao giờ chọn một số lượng commit gần đây một cách tùy tiện làm mốc cơ sở. Phân biệt rõ các thay đổi thuộc phần triển khai với các thay đổi tồn tại từ trước hoặc không liên quan trong kho mã nguồn. Nếu không thể xác lập ranh giới một cách đáng tin cậy, hãy nêu rõ hạn chế đó và chỉ hậu kiểm những gì có thể quy thuộc với độ tin cậy hợp lý. Không tự bịa ra các yêu cầu còn thiếu, tiêu chí nghiệm thu, lịch sử hoặc ranh giới triển khai. ## 2. Xác minh yêu cầu, tính chính xác và lỗi hồi quy Đối chiếu các yêu cầu và tiêu chí nghiệm thu hiện có với phần mã đã triển khai. Kiểm tra các vấn đề thực sự có ý nghĩa như: - hành vi bị thiếu hoặc mới chỉ hoàn thành một phần; - hiểu sai yêu cầu; - lỗi hồi quy (regressions) hoặc thay đổi hành vi ngoài ý muốn; - phình đại phạm vi (scope creep) hoặc các sửa đổi không liên quan; - sai sót về logic hoặc chuyển đổi trạng thái; - các luồng xử lý lỗi hoặc thất bại có liên quan; - các vấn đề có khả năng xảy ra ở điều kiện biên, vòng đời (lifecycle), bất đồng bộ (async), đồng thời (concurrency), lưu trữ bền vững (persistence), bộ nhớ đệm (caching) hoặc tích hợp hệ thống. Kiểm tra đủ phần mã nguồn xung quanh để hiểu rõ hành vi đã thay đổi. Chỉ điều tra các vùng rủi ro thực sự hợp lý đối với phần triển khai đó. Chỉ rà soát các vấn đề về bảo mật, hiệu năng, tính toàn vẹn dữ liệu hoặc triển khai hệ thống (deployment) khi thay đổi đang xét có liên quan đến chúng. Không báo cáo các sở thích chủ quan về phong cách viết mã hoặc những khả năng mang tính suy đoán xa vời thành lỗi (defects). Một mối quan ngại về khả năng bảo trì chỉ được coi là một phát hiện lỗi (finding) khi nó tạo ra rủi ro cụ thể về tính chính xác, khả năng rà soát, độ an toàn khi thay đổi hoặc rủi ro kỹ thuật dài hạn. ## 3. Xác minh bằng chứng Chạy tập hợp nhỏ nhất các lệnh kiểm chứng không làm biến đổi dữ liệu (non-mutating) có liên quan để đạt được độ tin cậy cần thiết. Mở rộng phạm vi kiểm chứng khi phạm vi thay đổi hoặc mức độ rủi ro đòi hỏi. Không bao giờ tuyên bố rằng một câu lệnh, bài kiểm thử, luồng thực thi hoặc hành vi đã được xác minh trừ khi nó thực sự đã được chạy kiểm tra. Nếu không thể hoàn tất bước kiểm chứng quan trọng, hãy ghi nhận rõ: - những gì chưa được xác minh; - lý do tại sao; - mức độ tin cậy bị giảm sút ở điểm nào. Một hạn chế về kiểm chứng không tự động trở thành một phát hiện lỗi (finding). Chỉ coi đó là một phát hiện lỗi khi bản thân việc thiếu kiểm chứng đó vi phạm một yêu cầu tường minh hoặc thể hiện một khiếm khuyết cụ thể. ## 4. Rà soát các bản commit khi có áp dụng Khi các bản commit là một phần của nội dung triển khai được hậu kiểm, hãy xác minh rằng mỗi commit đại diện cho một mối quan tâm mạch lạc duy nhất và có thể hiểu độc lập, dễ rà soát cũng như có thể hoàn tác hợp lý. Báo cáo các vấn đề trọng yếu như: - trộn lẫn các mối quan tâm không liên quan với nhau; - ngầm mở rộng phạm vi; - vô tình đưa vào các thay đổi không liên quan; - ranh giới phân tách commit gây hiểu lầm; - kích thước commit quá lớn làm ảnh hưởng nghiêm trọng đến khả năng rà soát hoặc độ an toàn khi quay lui (rollback). Không yêu cầu phải có commit khi nhiệm vụ không cho phép hoặc không kỳ vọng tạo commit. ## 5. Hoàn thành toàn bộ quá trình hậu kiểm Không dừng lại ngay ở vấn đề đầu tiên phát hiện được. Hoàn thành toàn bộ việc rà soát trong phạm vi và thu thập mọi phát hiện có ý nghĩa được củng cố bởi bằng chứng. Mỗi phát hiện phải được chứng minh bằng mã nguồn, bản diff, kết quả chạy kiểm thử, kết quả chạy lệnh, hành vi có thể tái hiện, hoặc một luồng gây lỗi cụ thể đáng tin cậy. Phân biệt rõ giữa dữ kiện thực tế (fact) và suy luận (inference). Sau khi hoàn thành việc hậu kiểm, hãy báo cáo kết quả và dừng lại. Không tự thực hiện việc sửa lỗi. ## Mức độ nghiêm trọng (Severity) Chỉ gắn mức độ nghiêm trọng cho các phát hiện lỗi thực sự: **Critical** — lỗi thảm khốc, xâm phạm bảo mật nghiêm trọng, mất mát/hỏng dữ liệu không thể khôi phục, hoặc hành vi cốt lõi hoàn toàn không thể sử dụng được. **High** — hành vi sai lệch lớn, lỗi hồi quy nghiêm trọng, thất bại đáng kể về bảo mật/độ tin cậy, hoặc không đáp ứng được một yêu cầu quan trọng. **Medium** — khiếm khuyết thực tế cần khắc phục nhưng có phạm vi ảnh hưởng giới hạn. **Low** — khiếm khuyết nhỏ nhưng hợp lệ và có tác động cụ thể ở mức hạn chế. Không sử dụng mức `Low` cho các góp ý trau chuốt tùy chọn hoặc sở thích chủ quan. ## Kết quả (Result) Chỉ sử dụng chính xác một kết quả chính: ### CLEAR Sử dụng khi: - không còn phát hiện lỗi có ý nghĩa nào; - hành vi mục tiêu đã được thiết lập đầy đủ; - các bước kiểm chứng liên quan đã hoàn tất thành công; - không còn khoảng trống kiểm chứng trọng yếu nào chưa được giải thích; - không có sự pha tạp phạm vi trọng yếu nào. ### FINDINGS Sử dụng khi tồn tại một hoặc nhiều phát hiện lỗi triển khai có ý nghĩa. Các hạn chế về kiểm chứng có thể được báo cáo kèm theo kết quả `FINDINGS`. ### INCOMPLETE Sử dụng khi chưa xác lập được khiếm khuyết triển khai có ý nghĩa nào, nhưng việc thiếu ngữ cảnh hoặc thiếu bước kiểm chứng quan trọng khiến không thể đưa ra kết luận `CLEAR` một cách đáng tin cậy. Không coi việc không tìm thấy lỗi là bằng chứng chứng minh mã nguồn đã hoàn toàn đúng. ## Hậu kiểm lại (Re-audit) Khi có sẵn danh sách các phát hiện từ lần trước, hãy giữ nguyên mã định danh (ID) của chúng và đánh dấu từng mục: - `RESOLVED` - `UNRESOLVED` - `NOT VERIFIED` Xác minh tận gốc vấn đề nền tảng chứ không chỉ nhìn vào triệu chứng bề mặt, đồng thời kiểm tra xem việc khắc phục có làm phát sinh lỗi hồi quy mới hay không. Sau đó thực hiện một đợt hậu kiểm mới đối với phạm vi bị ảnh hưởng. Không tự bịa ra các mã ID phát hiện trước đó khi chúng không có sẵn. ## Đầu ra Bắt đầu bằng: **Result:** `CLEAR` / `FINDINGS` / `INCOMPLETE` Nêu ngắn gọn: - phạm vi được hậu kiểm; - mốc cơ sở (baseline) đã sử dụng; - các bằng chứng quan trọng đã kiểm tra; - các lệnh kiểm chứng thực tế đã chạy; - các hạn chế trọng yếu. Đối với mỗi phát hiện mới, bao gồm: **ID:** `AUDIT-001` **Severity:** Critical / High / Medium / Low **Evidence:** bằng chứng cụ thể làm căn cứ **Impact:** lỗi hoặc rủi ro cụ thể **Recommended remediation:** cách sửa lỗi phù hợp nhỏ nhất **Verification:** cách để lần hậu kiểm lại có thể chứng minh vấn đề đã được giải quyết Đối với các phát hiện được hậu kiểm lại, bổ sung thêm: **Status:** RESOLVED / UNRESOLVED / NOT VERIFIED Đối với kết quả `INCOMPLETE`, hãy nêu rõ bằng chứng nào đang bị thiếu. Đối với kết quả `CLEAR`, hãy nêu rõ rằng không còn phát hiện lỗi có ý nghĩa nào và tóm tắt các bằng chứng củng cố cho kết luận đó. Không tự bịa ra người phụ trách, thời hạn, số liệu đo lường, phát hiện lỗi hoặc khuyến nghị chỉ để làm cho bản báo cáo trông có vẻ đầy đủ hơn.
Kiểm tra khả năng tiếp cận (accessibility) cho các tệp README và tài liệu Markdown: thiếu văn bản thay thế (alt text) hoặc dùng tên tệp làm alt text, cấp độ tiêu đề, văn bản liên kết mơ hồ hoặc trùng lặp, URL trần, tiêu đề bảng để trống, hướng dẫn chỉ dựa vào màu sắc, chữ IN HOA TOÀN BỘ và dấu đầu dòng bằng emoji, sau đó viết alt text có mục đích rõ ràng và trả về tệp đã chỉnh sửa. Bao gồm kịch bản kiểm tra bằng Python thư viện chuẩn đã được kiểm thử dùng cho CI.
---
name: markdown-accessibility-checker
description: Đánh giá tài liệu Markdown (README, trang tài liệu, wiki, bài viết blog, nhật ký thay đổi/changelogs) về khả năng tiếp cận (accessibility) - thiếu hoặc viết kém văn bản thay thế (alt text) cho hình ảnh, cấu trúc tiêu đề, văn bản liên kết mơ hồ hoặc trùng lặp, URL trần, tiêu đề bảng để trống, hướng dẫn chỉ dựa vào màu sắc, chữ IN HOA TOÀN BỘ và dấu đầu dòng bằng emoji - sau đó viết alt text chuẩn xác cho từng hình ảnh dựa theo ngữ cảnh và trả về danh sách các điểm cần sửa hoặc tệp đã hoàn chỉnh. Sử dụng khi người dùng yêu cầu "kiểm tra khả năng tiếp cận cho tệp README của tôi", "viết alt text cho những hình ảnh này", "làm cho tài liệu của chúng tôi thân thiện với trình đọc màn hình", hoặc muốn tích hợp bước kiểm tra khả năng tiếp cận cho Markdown vào quy trình CI.
---
# Công cụ Kiểm tra Khả năng Tiếp cận Markdown (Markdown Accessibility Checker)
Bạn giúp các tài liệu Markdown trở nên dễ tiếp cận với tất cả mọi người, bao gồm những người sử dụng trình đọc màn hình (screen readers), điều hướng bằng bàn phím, công cụ phóng đại màn hình hoặc thiết bị hỗ trợ đọc. Bạn phát hiện những vấn đề mà kịch bản tự động có thể nhận diện, rồi thực hiện phần việc mà kịch bản không thể làm thay: viết văn bản thay thế (alt text) và văn bản liên kết có ý nghĩa, bám sát mục đích của tài liệu.
## Các tệp trong kỹ năng này
- `scripts/check_md_a11y.py` - quét các tệp hoặc thư mục Markdown và báo cáo các vấn đề kèm theo số dòng và mức độ nghiêm trọng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/alt-text-guide.md` - hướng dẫn cách viết alt text theo từng loại hình ảnh (ảnh chụp màn hình, sơ đồ, biểu đồ, ảnh chụp, logo, huy hiệu/badges, hình ảnh trang trí)
- `references/structure-and-links.md` - tiêu đề, văn bản liên kết, bảng biểu, danh sách, nhấn mạnh câu chữ, và ý nghĩa của từng quy tắc trong kịch bản
- `templates/a11y-review.md` - mẫu báo cáo đánh giá và bảng làm việc soạn thảo alt text
- `examples/example-readme-review.md` - ví dụ thực tế về việc đánh giá và sửa lỗi tệp README của một dự án
## Quy trình làm việc
### 1. Xác định phạm vi đánh giá
Hỏi người dùng cần kiểm tra những tệp hoặc thư mục nào, đối tượng độc giả là ai (lập trình viên, người dùng cuối, sinh viên), và liệu người dùng muốn nhận báo cáo đánh giá, tệp đã sửa lỗi, hay cả hai. Nếu tài liệu được kết xuất bởi một trình tạo trang web tĩnh (site generator), hãy hỏi xem hệ thống đó có tự động thêm tiêu đề trang riêng hay không (khi đó tệp Markdown có thể không cần thẻ H1).
### 2. Chạy công cụ kiểm tra
```bash
python3 scripts/check_md_a11y.py README.md docs/
python3 scripts/check_md_a11y.py docs/ --json
python3 scripts/check_md_a11y.py docs/ --fail-on warn # for CI
```
Công cụ sẽ bỏ qua các khối mã (fenced code blocks) và mã nội tuyến (inline code). Mức `HIGH` có nghĩa là nội dung bị mất đối với một số độc giả (thiếu alt text, liên kết rỗng, tiêu đề rỗng), mức `WARN` là các lỗi nên sửa, mức `INFO` là các điểm cần con người xem xét trực tiếp. Nếu bạn không thể chạy kịch bản, hãy kiểm tra theo đúng các quy tắc đó bằng cách đọc tệp và nói rõ cho người dùng biết.
### 3. Viết văn bản thay thế (alt text)
Đối với mỗi hình ảnh bị gắn cờ cảnh báo, và đối với mỗi hình ảnh đã có sẵn alt text, hãy xác định mục đích của nó dựa theo `references/alt-text-guide.md`:
- Đọc phần văn bản xung quanh hình ảnh: hình ảnh này xuất hiện ở vị trí này trong tài liệu nhằm mục đích gì?
- Nếu bạn nhìn thấy hình ảnh, hãy mô tả những chi tiết quan trọng phục vụ cho mục đích đó. Nếu bạn không nhìn thấy hình ảnh, hãy hỏi người dùng, hoặc viết một bản nháp có đánh dấu rõ ràng dựa trên tên tệp và ngữ cảnh (ví dụ: "BẢN NHÁP: cần đối chiếu lại với hình ảnh thực tế").
- Các hình ảnh mang tính chất trang trí thuần túy thì để alt text rỗng; trong Markdown, tốt nhất nên xóa bỏ chúng hoặc sử dụng thẻ HTML `<img alt="">`.
Điền vào bảng làm việc alt text trong mẫu `templates/a11y-review.md`.
### 4. Sửa cấu trúc và các liên kết
Áp dụng các nguyên tắc trong `references/structure-and-links.md`: chỉ dùng một tiêu đề H1 duy nhất, không nhảy cóc cấp độ tiêu đề, dùng thẻ tiêu đề thực sự thay vì chỉ in đậm cả dòng, đặt văn bản liên kết có tính mô tả và không trùng lặp, bảng biểu phải có hàng tiêu đề, các câu hướng dẫn không được chỉ dựa vào màu sắc đơn thuần.
### 5. Bàn giao kết quả
- Báo cáo: điền vào mẫu `templates/a11y-review.md`, tương tự như trong `examples/example-readme-review.md`, ưu tiên liệt kê các mục sửa lỗi quan trọng nhất lên đầu.
- Tệp đã chỉnh sửa: trả về toàn bộ nội dung Markdown đã được áp dụng các bản sửa lỗi kèm theo danh sách ngắn gọn những thay đổi đã thực hiện. Không làm thay đổi ý nghĩa gốc, các đoạn mã mẫu hay đích đến của các liên kết.
- Chạy lại công cụ kiểm tra trên tệp đã sửa và đính kèm số liệu thống kê lỗi trước và sau khi sửa.
## Quy tắc
- Tuyệt đối không tự bịa ra nội dung hiển thị bên trong một bức ảnh. Nếu bạn chưa trực tiếp nhìn thấy bức ảnh đó, hãy đánh dấu alt text là bản nháp.
- Giữ cho alt text ngắn gọn (thường dưới 150 ký tự); đưa các phần giải thích dài (như số liệu biểu đồ, sơ đồ phức tạp) vào nội dung văn bản chính hoặc dòng chú thích ảnh, đồng thời ghi chú rõ điều đó trong alt text.
- Không bắt đầu alt text bằng cụm từ "hình ảnh của" ("image of") hay "bức tranh về" ("picture of").
- Hãy nhớ rằng các bài kiểm tra tự động chỉ phát hiện được một phần vấn đề; luôn nhắc người dùng những hạng mục vẫn cần kiểm tra thủ công (độ tương phản màu sắc bên trong hình ảnh, thứ tự đọc, phụ đề video).
- Tôn trọng giọng văn của tác giả; chỉ sửa lỗi về khả năng tiếp cận, không tự ý sửa văn phong.
FILE:references/alt-text-guide.md
# Hướng dẫn viết văn bản thay thế (Alt text)
Văn bản thay thế (Alt text) đóng vai trò thay thế cho hình ảnh đối với những người không thể nhìn thấy ảnh. Hãy tự hỏi: "Nếu tôi đọc to tài liệu này lên cho người khác nghe, đến vị trí này tôi sẽ nói gì thay vì giơ bức ảnh ra?" Câu trả lời phụ thuộc vào lý do vì sao bức ảnh được đặt ở đó, chứ không chỉ phụ thuộc vào những gì có mặt trong ảnh.
## Nguyên tắc cơ bản
- Cụ thể và ngắn gọn: thường từ 5 đến 20 từ, hiếm khi vượt quá 150 ký tự.
- Đưa phần thông tin quan trọng nhất lên đầu. Không bắt đầu bằng "hình ảnh về", "bức ảnh chụp" hay "ảnh chụp màn hình của" trừ khi bản thân loại hình ảnh đó mang ý nghĩa quan trọng (từ "Ảnh chụp màn hình" có thể hữu ích khi người đọc cần tìm đúng màn hình tương tự trên thiết bị).
- Kết thúc bằng dấu chấm câu để trình đọc màn hình ngắt nhịp tự nhiên.
- Không lặp lại nguyên văn từng chữ của dòng chú thích ảnh (caption) hoặc câu văn nằm ngay cạnh bức ảnh.
- Đưa cả phần chữ xuất hiện bên trong bức ảnh vào alt text nếu người đọc cần biết thông tin đó (nhãn nút bấm, thông báo lỗi).
## Theo từng loại hình ảnh
### Ảnh chụp màn hình của ứng dụng hoặc trang web
Nêu tên màn hình và một điểm quan trọng nhất mà người đọc cần chú ý.
- Kém: "screenshot" ("ảnh chụp màn hình")
- Tốt hơn: "Settings screen with the Units toggle set to Metric." ("Màn hình Cài đặt với nút chuyển đổi Đơn vị được đặt thành Hệ mét.")
### Ảnh chủ đạo (Hero images) và biểu ngữ (Banners)
Nếu bức ảnh chỉ nhằm tạo bầu không khí cảm xúc, đó là ảnh trang trí. Nếu bức ảnh giới thiệu sản phẩm, hãy mô tả nó trong một câu.
- "Trailhead Weather on a phone, showing a three-day forecast for a mountain pass." ("Ứng dụng Trailhead Weather trên điện thoại, hiển thị dự báo thời tiết ba ngày cho một đèo núi.")
### Sơ đồ và lưu đồ (Diagrams and flowcharts)
Nêu ý chính trong phần alt text, và trình bày toàn bộ cấu trúc chi tiết trong văn bản chính hoặc một danh sách ngay bên dưới bức ảnh.
- Alt: "Architecture diagram: the mobile app calls the API, which reads from a forecast cache and a weather provider. Details in the list below." ("Sơ đồ kiến trúc: ứng dụng di động gọi API, API này đọc dữ liệu từ bộ nhớ đệm dự báo và nhà cung cấp thời tiết. Chi tiết nằm ở danh sách bên dưới.")
### Biểu đồ và đồ thị (Charts and graphs)
Nêu loại biểu đồ, chủ đề và kết luận chính; đưa dữ liệu chi tiết vào bảng nếu người đọc cần tra cứu con số cụ thể.
- "Line chart of daily active users, January to June 2026, rising from 2,000 to 9,500 with a dip in April." ("Biểu đồ đường thể hiện số người dùng hoạt động hàng ngày từ tháng 1 đến tháng 6 năm 2026, tăng từ 2.000 lên 9.500 với một đợt giảm nhẹ vào tháng 4.")
### Ảnh chụp thực tế (Photos)
Mô tả những yếu tố quan trọng phục vụ cho mục đích của tài liệu: chủ thể, hành động, bối cảnh.
- "Snow-covered ridge trail with orange marker poles leading to a hut." ("Đường mòn trên sống núi phủ đầy tuyết với những cọc tiêu màu cam dẫn tới một trạm nghỉ.")
### Logo
Sử dụng tên tổ chức hoặc tên sản phẩm, và chỉ thêm chữ "logo" nếu điều đó giúp làm rõ nghĩa: "Trailhead Weather logo."
Khi logo đồng thời là một đường liên kết, hãy mô tả đích đến của liên kết đó: "Trailhead Weather home." ("Trang chủ Trailhead Weather.")
### Huy hiệu trạng thái (Badges: trạng thái bản dựng, phiên bản, giấy phép)
Viết đúng thông tin mà huy hiệu đó thể hiện: "Build: passing", "License: MIT", "Version 2.4.1".
### Biểu tượng (Icons) nằm trong văn bản hoặc liên kết
Nếu biểu tượng mang một chức năng cụ thể, hãy mô tả chức năng đó ("Tải xuống"); nếu nó chỉ lặp lại nội dung chữ viết ngay bên cạnh, thì đó là biểu tượng trang trí.
### Ảnh động GIF và video ngắn
Mô tả diễn biến xảy ra: "Animation: dragging a pin onto the map adds a new trailhead to the list." ("Hoạt ảnh: kéo một ghim thả vào bản đồ sẽ thêm một điểm xuất phát đường mòn mới vào danh sách.") Video cũng cần có phụ đề và, đối với nội dung quan trọng, cần có văn bản mô tả thay thế.
### Hình ảnh trang trí (Decorative images)
Đường kẻ phân cách, kết cấu nền và các chi tiết hoa văn thuần túy thị giác. Trong HTML, hãy dùng `alt=""` để trình đọc màn hình bỏ qua chúng. Trong Markdown, cú pháp `` mang tính nhập nhằng (thường bị hiểu là quên viết alt text), vì vậy tốt nhất nên xóa bỏ hình ảnh đó hoặc dùng thẻ `<img src="divider.png" alt="">`.
## Những sai lầm thường gặp
- Dùng nguyên tên tệp làm alt text: "IMG_2041.jpg", "hero-final-v3".
- Chỉ viết vỏn vẹn một từ: "image", "logo", "chart".
- Nhồi nhét từ khóa cho công cụ tìm kiếm (SEO).
- Viết alt text khác nhau cho cùng một bức ảnh được sử dụng ở nhiều nơi.
- Sa đà vào mô tả màu sắc hoặc bố cục trình bày trong khi thứ người đọc cần là thông tin cốt lõi chứ không phải hình thức bên ngoài.
FILE:references/structure-and-links.md
# Cấu trúc, liên kết và ý nghĩa của từng quy tắc
Người dùng trình đọc màn hình thường di chuyển nhanh qua tài liệu bằng danh sách tiêu đề hoặc danh sách liên kết. Một cấu trúc tốt chính là yếu tố giúp việc điều hướng đó khả thi.
## Tiêu đề (Headings)
| Quy tắc | Mức độ | Lý do | Cách sửa |
|---|---|---|---|
| empty-heading | HIGH | Một tiêu đề để trống sẽ được đọc lên là "tiêu đề cấp 2" mà không có bất kỳ nội dung nào phía sau. | Thêm nội dung chữ hoặc xóa bỏ dấu #. |
| no-h1 | WARN | Trang tài liệu không có tiêu đề chính trong sơ đồ dàn ý tiêu đề. | Bắt đầu bằng một dòng `# Tiêu đề`. Bỏ qua lỗi này nếu trình tạo trang web đã tự thêm H1 từ phần khai báo đầu tệp (front matter). |
| multiple-h1 | WARN | Việc có nhiều tiêu đề cấp 1 gây rối loạn sơ đồ dàn ý của trang. | Chỉ giữ lại một thẻ H1 duy nhất; sử dụng `##` cho các phần mục lớn. |
| heading-skip | WARN | Nhảy cóc từ H2 xuống thẳng H4 khiến người nghe tưởng rằng bị thiếu mất một mục ở giữa. | Sử dụng cấp độ kế tiếp ngay bên dưới (H3). Chọn cấp độ tiêu đề dựa theo cấu trúc phân cấp nội dung, không chọn vì kích thước phông chữ. |
| fake-heading | INFO | Một dòng chữ in đậm trông giống như tiêu đề nhưng lại không xuất hiện trong cây dàn ý của trình đọc màn hình. | Sử dụng thẻ tiêu đề thực sự nếu dòng đó mở đầu một mục mới; chỉ giữ nguyên in đậm nếu mục đích đơn thuần là nhấn mạnh câu. |
| no-headings | INFO | Văn bản dài không có tiêu đề phân mục rất khó đọc lướt và định vị thông tin. | Thêm tiêu đề phân mục sau mỗi vài đoạn văn. |
## Liên kết (Links)
| Quy tắc | Mức độ | Lý do | Cách sửa |
|---|---|---|---|
| empty-link | HIGH | Liên kết không có tên hiển thị để trình đọc màn hình đọc lên. | Thêm văn bản mô tả rõ ràng. |
| vague-link | WARN | Các cụm từ "Bấm vào đây" ("Click here") hoặc "xem thêm" ("read more") hoàn toàn vô nghĩa khi được liệt kê tách rời trong danh sách liên kết. | Nêu rõ tên đích đến: "tải xuống bản phát hành mới nhất", "hướng dẫn cài đặt". |
| same-text-different-url | WARN | Hai liên kết cùng có chữ "tài liệu" ("docs") nhưng trỏ tới hai địa chỉ khác nhau sẽ không thể phân biệt được. | Đặt văn bản hiển thị riêng biệt cho từng liên kết: "tài liệu tham khảo API", "hướng dẫn cài đặt". |
| url-as-link-text | INFO | Các đường dẫn URL dài sẽ bị trình đọc màn hình đánh vần từng ký tự một. | Sử dụng cụm từ mô tả ngắn gọn; chỉ giữ nguyên URL hiển thị nếu người đọc bắt buộc phải sao chép trực tiếp đường dẫn đó. |
| bare-url | INFO | Tương tự như trên, đồng thời không phải lúc nào cũng bấm vào được. | Bọc URL trong một liên kết có văn bản mô tả. |
Mẹo viết văn bản liên kết: đặt những từ quan trọng nhất lên đầu, giữ ngắn gọn, nêu rõ khi liên kết mở ra một tệp tải về ("bảng báo giá (PDF, 2 MB)"), và không viết từ "liên kết" ("link") vào trong văn bản hiển thị.
## Hình ảnh (Images)
| Quy tắc | Mức độ | Lý do |
|---|---|---|
| img-missing-alt | HIGH | `` không cung cấp văn bản thay thế nào; một số trình đọc màn hình sẽ đọc to tên tệp ảnh. |
| img-no-alt | HIGH | Thẻ `<img>` không có thuộc tính alt; trình đọc màn hình buộc phải đọc tên tệp ảnh. |
| img-generic-alt | WARN | Những từ chung chung như "image", "screenshot", "logo" đứng một mình không mang lại thông tin gì. |
| img-filename-alt | WARN | "IMG_2041.jpg" là tên tệp chứ không phải lời mô tả hình ảnh. |
| img-long-alt | WARN | Văn bản alt quá dài không thể tua hay điều hướng từng phần được; hãy chuyển nội dung chi tiết ra ngoài văn bản chính. |
| img-redundant-alt | INFO | Cụm từ "Hình ảnh của" ("Image of") bị thừa vì bản thân trình đọc màn hình đã tự thông báo đó là "hình ảnh". |
| img-decorative | INFO | `alt=""` sẽ ẩn hình ảnh khỏi trình đọc màn hình; hãy xác nhận chắc chắn rằng bức ảnh đó thuần túy chỉ để trang trí. |
Xem `alt-text-guide.md` để biết cách viết nội dung văn bản thay thế chuẩn xác.
## Bảng biểu (Tables)
- table-empty-header (WARN): mỗi cột trong bảng đều cần có ô tiêu đề để trình đọc màn hình có thể đọc khớp ngữ cảnh như "Bản Pro, Số ngày dự báo, 10". Hãy thêm tiêu đề cột như "Tính năng" vào cột đầu tiên.
- Chỉ sử dụng bảng để trình bày dữ liệu, không dùng bảng để dàn trang (layout). Giữ bảng nhỏ gọn; tách nhỏ các bảng có quá nhiều cột.
## Các quy tắc khác
- color-only (WARN): Câu hướng dẫn "Nhấn vào nút màu xanh lá" sẽ gây khó khăn cho người mù màu và người dùng trình đọc màn hình. Hãy nêu rõ tên nút bấm: "Chọn Lưu (nút màu xanh lá)".
- all-caps (INFO): Đoạn văn bản dài VIẾT HOA TOÀN BỘ rất khó đọc, và một số trình đọc màn hình sẽ đánh vần từng chữ cái đối với các từ viết hoa ngắn. Hãy sử dụng chữ viết thường đúng ngữ pháp và dùng định dạng in đậm để nhấn mạnh.
- emoji-bullet (INFO): Các biểu tượng emoji sẽ bị đọc to bằng tên mô tả dài dòng ("dấu kiểm đậm màu trắng Dự báo hàng giờ"). Hãy sử dụng dấu đầu dòng danh sách tiêu chuẩn, đồng thời hạn chế dùng emoji và chỉ dùng khi thực sự mang lại ý nghĩa.
## Các hạng mục kịch bản không kiểm tra được (cần kiểm tra thủ công)
- Nội dung văn bản thay thế (alt text) có thực sự chính xác và hữu ích hay không.
- Độ tương phản màu sắc và chữ viết nằm bên trong hình ảnh (ảnh chụp màn hình có cỡ chữ quá nhỏ, biểu đồ có độ tương phản thấp).
- Thứ tự đọc của các bố cục phức tạp và các khối mã HTML nhúng bên trong Markdown.
- Phụ đề (captions) và bản chép lời (transcripts) cho video và âm thanh.
- Ngôn ngữ giản dị, dễ hiểu: câu văn ngắn gọn, giải thích rõ các thuật ngữ chuyên ngành và đặt tên các phần mục mang tính mô tả cao.
FILE:templates/a11y-review.md
# Báo cáo Đánh giá Khả năng Tiếp cận: <tài liệu hoặc thư mục>
Ngày: <YYYY-MM-DD>
Số tệp đã kiểm tra: <n> (<danh sách hoặc thư mục>)
Đối tượng độc giả: <ai là người đọc các tài liệu này>
Kết quả chạy công cụ kiểm tra: `python3 scripts/check_md_a11y.py <paths>` -> HIGH <n>, WARN <n>, INFO <n>
## Các mục ưu tiên sửa hàng đầu (tác động lớn nhất xếp trước)
1. <nội dung sửa> (<tệp:dòng>)
2. <nội dung sửa>
3. <nội dung sửa>
## Bảng làm việc văn bản thay thế (Alt text worksheet)
| Tệp:dòng | Hình ảnh | Mục đích trong tài liệu | Alt hiện tại | Alt mới | Đã xem ảnh trực tiếp? |
|---|---|---|---|---|---|
| | | cung cấp thông tin / chức năng / trang trí / phức tạp | | | đã xem / bản nháp |
## Tiêu đề (Headings)
- Dàn ý hiện tại: <H1 ... / H2 ... / H4 ...>
- Dàn ý đề xuất: <H1 ... / H2 ... / H3 ...>
## Liên kết (Links)
| Tệp:dòng | Văn bản hiện tại | Văn bản mới | Đích đến |
|---|---|---|---|
| | | | |
## Các phát hiện khác
| Tệp:dòng | Quy tắc | Thay đổi thực hiện |
|---|---|---|
| | | |
## Sau khi sửa lỗi
Chạy lại công cụ kiểm tra: HIGH <n>, WARN <n>, INFO <n> (các mục INFO còn lại được giải thích bên dưới).
- <mục INFO: lý do vì sao có thể chấp nhận giữ nguyên>
## Các hạng mục vẫn cần kiểm tra thủ công
- <ví dụ: độ tương phản của chữ bên trong ảnh chụp màn hình, phụ đề video>
FILE:examples/example-readme-review.md
# Ví dụ: đánh giá khả năng tiếp cận cho tệp README của một dự án
## Yêu cầu từ người dùng
"Tệp README của ứng dụng mã nguồn mở bên tôi có lượng truy cập rất lớn. Một người dùng sử dụng trình đọc màn hình phản ánh rằng tài liệu rất khó theo dõi. Bạn có thể kiểm tra và sửa giúp tôi được không? Ảnh hero là hình ứng dụng chạy trên điện thoại hiển thị dự báo 3 ngày; app-main.png là màn hình chính có nhiệt độ hàng giờ, sức gió và đường ranh giới tuyết rơi; còn chiếc huy hiệu (badge) là trạng thái bản dựng (build status) của chúng tôi."
## Chạy công cụ kiểm tra (trước khi sửa)
```
$ python3 scripts/check_md_a11y.py README.md
Markdown accessibility check: 1 file(s), 3 image(s)
HIGH 2 WARN 8 INFO 5
README.md (3 images, 4 headings): 15 finding(s)
3 HIGH img-missing-alt Image hero.png has empty alt text; describe it, or confirm it is purely decorative.
5 INFO all-caps Long ALL CAPS text is hard to read and some screen readers spell it letter by letter.
9 WARN vague-link Link text "here" does not say where it goes; describe the destination.
9 WARN vague-link Link text "click here" does not say where it goes; describe the destination.
11 WARN img-generic-alt Alt text "screenshot" for app-main.png is generic; say what the image shows or why it is there.
13 HIGH img-no-alt <img src="docs/img/badge.svg"> has no alt attribute.
15 WARN heading-skip Heading level jumps from H2 to H4 ("Configuration"); use H3.
17 WARN color-only Instruction relies on color; add the label, shape, or position too.
19 INFO fake-heading Bold line "Features" looks like a heading; use a real # heading if it starts a section.
21 INFO emoji-bullet Emoji used as a bullet is read aloud by name; use a list marker and put meaning in words.
22 INFO emoji-bullet Emoji used as a bullet is read aloud by name; use a list marker and put meaning in words.
24 WARN table-empty-header Table header has 1 empty cell(s); every column needs a header.
28 WARN same-text-different-url Link text "docs" points to 2 different URLs; make each text unique.
28 INFO bare-url Bare URL https://example.org/docs; screen readers spell it out. Use [descriptive text](https://example.org/docs).
35 WARN multiple-h1 2 H1 headings (lines 1, 35); keep one title and use ## for sections.
```
Cú pháp `` nằm bên trong khối mã bash và ví dụ mã nội tuyến ở dòng cuối cùng đã được công cụ bỏ qua một cách chính xác.
## Báo cáo đánh giá (đã điền theo mẫu, rút gọn)
# Báo cáo Đánh giá Khả năng Tiếp cận: README.md
Đối tượng độc giả: người đi bộ đường dài (hikers) và các cộng tác viên mã nguồn mở, nhiều người tìm đến từ công cụ tìm kiếm.
Kết quả chạy công cụ kiểm tra: HIGH 2, WARN 8, INFO 5.
### Các mục ưu tiên sửa hàng đầu
1. Cung cấp văn bản thay thế (alt text) thực chất cho ảnh hero và huy hiệu trạng thái bản dựng (dòng 3 và 13).
2. Thay thế cụm từ "here" và "click here" bằng văn bản liên kết nêu rõ tên đích đến (dòng 9).
3. Sửa lại dàn ý tiêu đề: chuyển "Configuration" thành H3, chuyển "Features" thành thẻ tiêu đề H3 thực sự, và hạ thẻ H1 thứ hai "Contributing" xuống thành H2.
4. Nêu rõ tên nút Save thay vì chỉ mô tả màu sắc của nút (dòng 17).
5. Thêm tiêu đề cho cột đầu tiên của bảng (dòng 24).
### Bảng làm việc văn bản thay thế (Alt text worksheet)
| Tệp:dòng | Hình ảnh | Mục đích | Alt hiện tại | Alt mới | Đã xem ảnh? |
|---|---|---|---|---|---|
| README.md:3 | hero.png | cung cấp thông tin: giới thiệu sản phẩm | (trống) | Trailhead Weather on a phone, showing a three-day forecast for a mountain pass. | do người dùng mô tả |
| README.md:11 | app-main.png | cung cấp thông tin: màn hình chính hiển thị những gì | screenshot | Main screen with hourly temperature, wind, and snow line for the selected trailhead. | do người dùng mô tả |
| README.md:13 | badge.svg | cung cấp thông tin: trạng thái bản dựng | (không có thuộc tính alt) | Build: passing | do người dùng mô tả |
### Tiêu đề (Headings)
- Trước khi sửa: H1 Trailhead Weather / H2 Install / H4 Configuration / H1 Contributing (cộng thêm một dòng chữ in đậm "Features").
- Sau khi sửa: H1 Trailhead Weather / H2 Install / H3 Configuration / H3 Features / H2 Contributing.
### Liên kết (Links)
| Dòng | Văn bản hiện tại | Văn bản mới | Đích đến |
|---|---|---|---|
| 9 | here | Download the latest release | https://example.org/releases |
| 9 | click here | setup guide | docs/guides/setup.md |
| 28 | docs (URL trần + "docs") | API reference | https://example.org/docs |
| 28 | docs | setup guide | docs/guides/setup.md |
### Các phát hiện khác
| Dòng | Quy tắc | Thay đổi thực hiện |
|---|---|---|
| 5 | all-caps | Chuyển về chữ thường đúng ngữ pháp bên trong phần ghi chú in đậm. |
| 17 | color-only | Đổi thành "Select Save (the green button at the bottom of Settings)". |
| 21-22 | emoji-bullet | Đổi thành các mục danh sách gạch đầu dòng "-" thông thường. |
| 24 | table-empty-header | Đặt tiêu đề cột đầu tiên là "Feature". |
### Tệp README sau khi sửa (trích đoạn)
```markdown
# Trailhead Weather

Trailhead Weather shows mountain forecasts for hikers. **Note: this project is still in beta and things will break often.**
## Install
[Download the latest release](https://example.org/releases), or follow the [setup guide](docs/guides/setup.md).
<img src="docs/img/badge.svg" width="90" alt="Build: passing">
### Configuration
Select Save (the green button at the bottom of Settings) to save your settings.
### Features
- Hourly forecasts
- Avalanche bulletins
| Feature | Free | Pro |
|---|---|---|
| Forecast days | 3 | 10 |
```
### Sau khi sửa lỗi
```
$ python3 scripts/check_md_a11y.py README-fixed.md
Markdown accessibility check: 1 file(s), 3 image(s)
HIGH 0 WARN 0 INFO 0
```
### Các hạng mục vẫn cần kiểm tra thủ công
- Ảnh hero có phần chữ nhỏ hiển thị trên màn hình điện thoại; văn bản thay thế (alt text) đã truyền tải đầy đủ thông điệp cốt lõi nên ở đây như vậy là ổn.
- Nếu sau này tệp README được đưa lên thành một trang trên website tài liệu, hãy kiểm tra để chắc chắn hệ thống tạo trang không tự động chèn thêm một thẻ H1 thứ hai.
## Vì sao đây là một phương án xử lý tốt
- Văn bản thay thế (alt text) mô tả đúng mục đích của từng bức ảnh dựa trên thông tin người dùng cung cấp thay vì tự suy đoán.
- Các liên kết và tiêu đề được chỉnh sửa phù hợp với cách thức điều hướng thực tế của người dùng trình đọc màn hình: duyệt theo danh sách liên kết và sơ đồ dàn ý tiêu đề.
- Bản sửa lỗi được kiểm chứng lại bằng cách chạy công cụ kiểm tra một lần nữa, đồng thời liệt kê trung thực những hạng mục vẫn cần con người kiểm tra thủ công.
FILE:scripts/check_md_a11y.py
#!/usr/bin/env python3
"""Check Markdown files for common accessibility problems.
Usage:
python3 check_md_a11y.py README.md docs/ # files and folders (*.md, *.markdown)
python3 check_md_a11y.py docs/ --json
cat page.md | python3 check_md_a11y.py -
python3 check_md_a11y.py docs/ --fail-on warn # exit 1 on WARN too (for CI)
Checks (code blocks and inline code are ignored):
images missing alt text, file-name or generic alt ("image", "screenshot"),
redundant "image of ..." openers, very long alt, HTML <img> without alt,
empty alt (fine only for decorative images)
headings no H1 or more than one H1, skipped levels (H2 -> H4), empty headings,
bold lines used as fake headings
links empty link text, vague text ("click here", "read more", "here"),
bare URLs, the same text pointing to different URLs
tables empty header cells
other ALL CAPS sentences, emoji used as bullets, color-only instructions
Severities: HIGH (blocks understanding), WARN (should fix), INFO (check by hand).
Exit code: 0 no HIGH, 1 HIGH found (or WARN with --fail-on warn), 2 usage error.
Standard library only.
"""
import argparse
import json
import os
import re
import sys
GENERIC_ALT = {"image", "img", "picture", "pic", "photo", "screenshot", "screen shot", "graphic", "icon",
"logo", "diagram", "chart", "figure", "untitled", "alt", "alt text", "banner", "placeholder"}
VAGUE_LINK = {"click here", "here", "click", "read more", "more", "learn more", "link", "this", "this link",
"this page", "go", "details", "more info", "info", "see here", "check it out"}
REDUNDANT_START = re.compile(r"^(an? )?(image|picture|photo|screenshot|graphic) (of|showing)\b", re.I)
FILE_ALT = re.compile(r"^[\w\-. ]+\.(png|jpe?g|gif|svg|webp|bmp|tiff?)$|^(img|image|dsc|screenshot)[_\- ]?\d+", re.I)
IMG_MD = re.compile(r"!\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+\"[^\"]*\")?\s*\)")
IMG_REF = re.compile(r"!\[([^\]]*)\]\[[^\]]*\]")
LINK_MD = re.compile(r"(?<!!)\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+\"[^\"]*\")?\s*\)")
HTML_IMG = re.compile(r"<img\b[^>]*>", re.I)
BARE_URL = re.compile(r"(?<![(<\"'=\[])\bhttps?://[^\s)>\]]+")
COLOR_ONLY = re.compile(r"\b(click|press|tap|select|choose|use)\b[^.]{0,30}\b(red|green|blue|yellow|orange|purple)\b(?! *\()", re.I)
EMOJI_BULLET = re.compile(r"^\s*[\u2705\u274c\u2b50\u27a1\u2714\u2716\U0001F300-\U0001FAFF]\ufe0f?\s+\S")
def strip_inline_code(line):
return re.sub(r"`+[^`]*`+", lambda m: " " * len(m.group(0)), line)
def check_text(text, name):
findings = []
def add(sev, line, rule, msg):
findings.append({"file": name, "line": line, "severity": sev, "rule": rule, "message": msg})
lines = text.replace("\r\n", "\n").split("\n")
in_fence, fence = False, ""
headings, link_targets = [], {}
images = 0
prev_blank = True
for no, raw in enumerate(lines, 1):
stripped = raw.strip()
m = re.match(r"^\s{0,3}(`{3,}|~{3,})", raw)
if m:
if not in_fence:
in_fence, fence = True, m.group(1)[0] * 3
elif stripped.startswith(fence):
in_fence = False
continue
if in_fence or raw.startswith(" ") and prev_blank and not stripped.startswith(("-", "*", "+")):
prev_blank = not stripped if not in_fence else prev_blank
continue
line = strip_inline_code(raw)
hm = re.match(r"^\s{0,3}(#{1,6})(\s+|$)(.*?)\s*#*\s*$", line)
if hm:
level, title = len(hm.group(1)), hm.group(3).strip()
headings.append((no, level, title))
if not title:
add("HIGH", no, "empty-heading", f"Empty H{level} heading; screen reader users navigate by headings.")
elif re.fullmatch(r"\s*(\*\*|__)[^*_]{2,80}(\*\*|__):?\s*", line) and prev_blank:
add("INFO", no, "fake-heading", f"Bold line \"{stripped.strip('*_:')}\" looks like a heading; use a real # heading if it starts a section.")
images += len(IMG_MD.findall(line)) + len(IMG_REF.findall(line)) + len(HTML_IMG.findall(line))
for alt, src in IMG_MD.findall(line):
check_alt(add, no, alt, src)
for alt in IMG_REF.findall(line):
check_alt(add, no, alt, "(reference)")
for tag in HTML_IMG.findall(line):
am = re.search(r"\balt\s*=\s*(\"([^\"]*)\"|'([^']*)'|(\S+))", tag, re.I)
src = re.search(r"\bsrc\s*=\s*[\"']?([^\"'\s>]+)", tag, re.I)
src = src.group(1) if src else "?"
if not am:
add("HIGH", no, "img-no-alt", f"<img src=\"{src}\"> has no alt attribute.")
else:
check_alt(add, no, am.group(2) if am.group(2) is not None else (am.group(3) if am.group(3) is not None else am.group(4)), src, html=True)
no_images = IMG_MD.sub(" ", line)
for text_, url in LINK_MD.findall(no_images):
label = re.sub(r"[*_`]", "", text_).strip()
if not label:
add("HIGH", no, "empty-link", f"Link to {url or '(no url)'} has no text; screen readers will read the URL or nothing.")
elif label.lower().rstrip(".!:") in VAGUE_LINK:
add("WARN", no, "vague-link", f"Link text \"{label}\" does not say where it goes; describe the destination.")
elif re.fullmatch(r"https?://\S+", label):
add("INFO", no, "url-as-link-text", f"Link text is a raw URL ({label[:60]}); use a short description.")
if label:
link_targets.setdefault(label.lower(), {}).setdefault(url, no)
for url in BARE_URL.findall(LINK_MD.sub(" ", no_images)):
add("INFO", no, "bare-url", f"Bare URL {url[:60]}; screen readers spell it out. Use [descriptive text]({url[:40]}).")
if "|" in line and re.match(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$", line) and no > 1:
header = lines[no - 2].strip().strip("|")
cells = [c.strip() for c in header.split("|")]
if any(not c for c in cells):
add("WARN", no - 1, "table-empty-header", f"Table header has {sum(1 for c in cells if not c)} empty cell(s); every column needs a header.")
run = longest = 0
for w in re.findall(r"[A-Za-z]+", line):
run = run + 1 if w.isupper() and (len(w) > 1 or w in "AI") else 0
longest = max(longest, run)
if longest >= 5 and not hm:
add("INFO", no, "all-caps", "Long ALL CAPS text is hard to read and some screen readers spell it letter by letter.")
if EMOJI_BULLET.match(line):
add("INFO", no, "emoji-bullet", "Emoji used as a bullet is read aloud by name; use a list marker and put meaning in words.")
cm = COLOR_ONLY.search(line)
if cm:
between = line[cm.end(1):cm.start(2)]
inside_parens = line[:cm.start(2)].count("(") > line[:cm.start(2)].count(")")
names_label = re.match(r"\s+(\*\*|\"|')?[A-Z]", between) # "Select Save ..." or "Press **Save**"
if inside_parens or names_label:
cm = None
if cm:
add("WARN", no, "color-only", "Instruction relies on color; add the label, shape, or position too.")
prev_blank = not stripped
h1 = [h for h in headings if h[1] == 1]
if headings and not h1:
add("WARN", headings[0][0], "no-h1", "Document has no H1; start with one # title.")
if len(h1) > 1:
add("WARN", h1[1][0], "multiple-h1", f"{len(h1)} H1 headings (lines {', '.join(str(h[0]) for h in h1)}); keep one title and use ## for sections.")
for (pno, plev, _), (no, lev, title) in zip(headings, headings[1:]):
if lev > plev + 1:
add("WARN", no, "heading-skip", f"Heading level jumps from H{plev} to H{lev} (\"{title[:50]}\"); use H{plev + 1}.")
if not headings and len([l for l in lines if l.strip()]) > 15:
add("INFO", 1, "no-headings", "Long document with no headings; add headings so readers can navigate.")
for label, targets in link_targets.items():
if len(targets) > 1 and label not in VAGUE_LINK:
first = min(targets.values())
add("WARN", first, "same-text-different-url", f"Link text \"{label}\" points to {len(targets)} different URLs; make each text unique.")
order = {"HIGH": 0, "WARN": 1, "INFO": 2}
findings.sort(key=lambda f: (f["line"], order[f["severity"]]))
return findings, len(headings), images
def check_alt(add, no, alt, src, html=False):
a = alt.strip()
base = os.path.basename(src) if src else ""
if a == "" and html:
add("INFO", no, "img-decorative", f"<img src=\"{base}\"> has alt=\"\" (marked decorative); confirm it carries no information.")
return
if a == "" and src:
add("HIGH", no, "img-missing-alt", f"Image {base or src} has empty alt text; describe it, or confirm it is purely decorative.")
return
low = a.lower().rstrip(".")
if low in GENERIC_ALT:
add("WARN", no, "img-generic-alt", f"Alt text \"{a}\" for {base} is generic; say what the image shows or why it is there.")
elif FILE_ALT.search(a) or (base and low == os.path.splitext(base)[0].lower()):
add("WARN", no, "img-filename-alt", f"Alt text \"{a}\" looks like a file name; describe the image instead.")
elif REDUNDANT_START.match(a):
add("INFO", no, "img-redundant-alt", f"Alt text starts with \"{' '.join(a.split()[:3])}\"; screen readers already say 'image', so start with the content.")
if len(a) > 150:
add("WARN", no, "img-long-alt", f"Alt text is {len(a)} characters; keep it under about 150 and move details to the surrounding text or a caption.")
def collect(paths):
files = []
for p in paths:
if p == "-":
files.append("-")
elif os.path.isdir(p):
for root, dirs, names in os.walk(p):
dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d != "node_modules")
files += [os.path.join(root, n) for n in sorted(names) if n.lower().endswith((".md", ".markdown"))]
elif os.path.isfile(p):
files.append(p)
else:
raise FileNotFoundError(p)
return files
def main():
ap = argparse.ArgumentParser(description="Markdown accessibility checker")
ap.add_argument("paths", nargs="+", help="Markdown files, folders, or - for stdin")
ap.add_argument("--json", action="store_true")
ap.add_argument("--fail-on", choices=["high", "warn"], default="high")
args = ap.parse_args()
try:
files = collect(args.paths)
except FileNotFoundError as e:
print(f"error: path not found: {e}", file=sys.stderr)
return 2
if not files:
print("error: no Markdown files (*.md, *.markdown) found", file=sys.stderr)
return 2
all_findings, stats = [], []
for f in files:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8", errors="replace").read()
name = "<stdin>" if f == "-" else f
found, nhead, images = check_text(text, name)
stats.append({"file": name, "images": images, "headings": nhead, "findings": len(found)})
all_findings += found
counts = {s: sum(1 for x in all_findings if x["severity"] == s) for s in ("HIGH", "WARN", "INFO")}
if args.json:
print(json.dumps({"files": stats, "counts": counts, "findings": all_findings}, indent=2))
else:
print(f"Markdown accessibility check: {len(files)} file(s), {sum(s['images'] for s in stats)} image(s)")
print(f"HIGH {counts['HIGH']} WARN {counts['WARN']} INFO {counts['INFO']}")
for s in stats:
items = [x for x in all_findings if x["file"] == s["file"]]
print(f"\n{s['file']} ({s['images']} images, {s['headings']} headings): {len(items)} finding(s)")
for x in items:
print(f" {x['line']:>4} {x['severity']:<4} {x['rule']:<24} {x['message']}")
print("\nAutomated checks cover only part of accessibility; review alt text meaning, reading order, and contrast by hand.")
bad = counts["HIGH"] + (counts["WARN"] if args.fail_on == "warn" else 0)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())Kiểm tra các tệp phụ đề SRT và WebVTT để phát hiện thời gian bị lỗi hoặc chồng lấp, khoảng trống đánh số thứ tự, thẻ phụ đề (cue) trống, tốc độ đọc vượt quá giới hạn ký tự trên giây, dòng quá dài hoặc thừa dòng, phụ đề lướt qua quá nhanh hoặc hiển thị quá lâu, thẻ định dạng không cân xứng và các nhãn âm thanh, sau đó viết lại cũng như căn chỉnh lại thời gian cho các đoạn phụ đề gặp sự cố và có thể dịch chuyển thời gian của toàn bộ tệp. Sử dụng khi người dùng chia sẻ phụ đề và yêu cầu "kiểm tra phụ đề giúp tôi", "tại sao những phụ đề này khó đọc thế?", "sửa lại thời gian hiển thị", hoặc "làm cho các phụ đề này tuân thủ đúng quy chuẩn".
---
name: srt-subtitle-quality-checker
description: Kiểm tra các tệp phụ đề SRT và WebVTT để phát hiện thời gian bị lỗi hoặc chồng lấp, khoảng trống đánh số thứ tự, thẻ phụ đề (cue) trống, tốc độ đọc vượt quá giới hạn ký tự trên giây, dòng quá dài hoặc thừa dòng, phụ đề lướt qua quá nhanh hoặc hiển thị quá lâu, thẻ định dạng không cân xứng và các nhãn âm thanh, sau đó viết lại cũng như căn chỉnh lại thời gian cho các đoạn phụ đề gặp sự cố và có thể dịch chuyển thời gian của toàn bộ tệp. Sử dụng khi người dùng chia sẻ phụ đề và yêu cầu "kiểm tra phụ đề giúp tôi", "tại sao những phụ đề này khó đọc thế?", "sửa lại thời gian hiển thị", hoặc "làm cho các phụ đề này tuân thủ đúng quy chuẩn".
---
# Trình Kiểm tra Chất lượng Phụ đề SRT (SRT Subtitle Quality Checker)
Bạn giúp các nhà làm video, biên dịch viên và đội ngũ hỗ trợ tiếp cận (accessibility) xuất bản những bản phụ đề mà người xem thực sự có thể đọc kịp và thoải mái. Bạn tìm ra các lỗi mốc thời gian làm hỏng trình phát video, các thẻ phụ đề (cues) xuất hiện quá chớp nhoáng hoặc chứa quá nhiều chữ, và các dòng quá dài, đồng thời khắc phục chúng bằng cách viết lại câu chữ cô đọng hơn và điều chỉnh lại thời gian mà không làm thay đổi ý nghĩa gốc.
## Các tệp trong kỹ năng này
- `scripts/check_subtitles.py` - phân tích cú pháp SRT và WebVTT, kiểm tra thời gian, đánh số thứ tự, tốc độ đọc, độ dài dòng, số lượng dòng, thời lượng hiển thị, khoảng nghỉ giữa các câu, thẻ định dạng và nhãn âm thanh, đồng thời có thể dịch chuyển toàn bộ mốc thời gian (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/subtitle-guidelines.md` - các giới hạn tiêu chuẩn phổ biến về tốc độ đọc, độ dài dòng, thời lượng hiển thị, khoảng nghỉ, ngắt dòng và thời điểm sử dụng nhãn SDH
- `references/condensing-techniques.md` - cách rút gọn văn bản phụ đề mà không làm mất ý nghĩa, và cách căn chỉnh lại thời gian cho các thẻ phụ đề (cues)
- `templates/subtitle-review.md` - mẫu báo cáo đánh giá để trả về cho người dùng
- `examples/example-workshop-video.md` - một bài đánh giá hoàn chỉnh thực tế cho một video hướng dẫn ngắn
## Quy trình làm việc
### 1. Thu thập thông tin thực tế
Hỏi người dùng, hoặc tự đặt giả định hợp lý và nêu rõ giả định đó:
- Tệp đầu vào (SRT hoặc VTT) và tổng thời lượng video; tốc độ khung hình nếu biết (24, 25 hoặc 30 fps).
- Đối tượng khán giả và quy chuẩn phong cách (style guide): khán giả trưởng thành nói chung, trẻ em, quy chuẩn của một nền tảng cụ thể, hay giới hạn riêng của khách hàng.
- Loại phụ đề: phụ đề cùng ngôn ngữ (captions), phụ đề dịch thuật, hay phụ đề SDH (dành cho người điếc và khiếm thính, có kèm nhãn mô tả âm thanh).
- Liệu bạn được phép viết lại rút gọn câu chữ hay chỉ được phép điều chỉnh mốc thời gian.
### 2. Chạy trình kiểm tra
```bash
python3 scripts/check_subtitles.py movie.srt
python3 scripts/check_subtitles.py movie.srt --max-cps 15 --max-line 37 # children or stricter guides
python3 scripts/check_subtitles.py movie.vtt --json
python3 scripts/check_subtitles.py movie.srt --shift -1200 > movie-synced.srt # whole file 1.2 s earlier
```
Giá trị mặc định: 17 ký tự mỗi giây, 42 ký tự mỗi dòng, tối đa 2 dòng, hiển thị từ 0.833 đến 7 giây trên màn hình, khoảng nghỉ tối thiểu 83 ms. Hãy chọn các ngưỡng giới hạn từ `references/subtitle-guidelines.md` sao cho phù hợp với đối tượng khán giả. Nếu bạn không thể chạy kịch bản kiểm tra, hãy kiểm tra thủ công từng thẻ phụ đề (cue): thời lượng hiển thị, số ký tự chia cho số giây, dòng dài nhất, và sự chồng lấp thời gian với thẻ phụ đề kế tiếp.
### 3. Sửa lỗi theo thứ tự ưu tiên sau
1. Xử lý các phát hiện mức HIGH trước tiên: chồng lấp thời gian, thẻ phụ đề sai thứ tự thời gian, thời điểm kết thúc đứng trước thời điểm bắt đầu, mốc thời gian sai định dạng, thẻ phụ đề trống. Những lỗi này làm hỏng trình phát hoặc làm ẩn mất chữ.
2. Tốc độ đọc (Reading speed): kéo dài thời gian hiển thị của thẻ phụ đề sang khoảng thời gian trống phía trước hoặc phía sau nó (tuân thủ khoảng nghỉ tối thiểu và các điểm chuyển cảnh mà người dùng đề cập), nếu không đủ thì rút gọn văn bản theo hướng dẫn trong `references/condensing-techniques.md`, nếu vẫn không được thì tách thẻ phụ đề thành hai phần tại một nhịp nghỉ tự nhiên.
3. Độ dài dòng và số lượng dòng: ngắt lại dòng tại ranh giới cụm từ tự nhiên; ưu tiên giữ cho dòng trên ngắn hơn khi có thể; tuyệt đối không ngắt dòng giữa mạo từ/lượng từ và danh từ đi kèm.
4. Hiển thị quá ngắn hoặc quá dài trên màn hình: gộp các thẻ phụ đề quá ngắn với thẻ liền kề, tách nhỏ các thẻ quá dài.
5. Các mục mức INFO: đánh lại số thứ tự, đóng mở thẻ định dạng cho cân xứng, xóa nhãn mô tả âm thanh khỏi các tệp không phải SDH, nối liền các thẻ phụ đề (khoảng nghỉ 0 ms) hoặc chừa một khoảng nghỉ đủ rõ.
Chạy lại trình kiểm tra trên tệp đã sửa cho đến khi không còn phát hiện mức HIGH nào và mọi cảnh báo mức WARN đều đã được sửa hoặc được giải thích rõ ràng.
### 4. Bàn giao kết quả
Điền vào mẫu `templates/subtitle-review.md`: bản tóm tắt kèm số liệu thống kê trước và sau khi sửa, các ngưỡng giới hạn đã áp dụng, bảng liệt kê các thẻ phụ đề đã thay đổi (thời gian và nội dung cũ so với mới), cùng toàn bộ tệp đã được chỉnh sửa hoàn chỉnh giữ đúng định dạng đầu vào. Tuân theo phong cách trình bày của `examples/example-workshop-video.md`.
## Quy tắc
- Tuyệt đối không làm thay đổi ý nghĩa, giọng điệu hoặc người nói của câu thoại; chỉ cắt gọn các từ đệm rườm rà chứ không cắt bỏ nội dung thông tin.
- Giữ nguyên chính xác tên riêng, con số và thuật ngữ kỹ thuật; nếu phát hiện một thuật ngữ có vẻ sai, hãy gắn cờ cảnh báo thay vì tự ý đoán mò.
- Mọi thay đổi về mốc thời gian phải giữ đồng bộ với lời nói: chỉ kéo dài thẻ phụ đề lấn vào khoảng im lặng, tối đa khoảng 0.5 giây trước khi lời nói bắt đầu hoặc 1 giây sau khi lời nói kết thúc, trừ khi người dùng cho phép nới rộng hơn.
- Giữ nguyên định dạng đầu vào (tệp SRT giữ nguyên SRT, tệp VTT giữ nguyên VTT) và bảng mã gốc (UTF-8).
- Nêu rõ những phát hiện nào bạn không thể tự sửa nếu không được nghe trực tiếp âm thanh hoặc xem hình ảnh video.
FILE:references/subtitle-guidelines.md
# Quy chuẩn phụ đề: các ngưỡng giới hạn phổ biến
Các đài truyền hình và nền tảng phát trực tuyến khác nhau đều công bố bộ quy chuẩn phong cách (style guide) riêng của họ. Các giá trị dưới đây là những mốc khởi đầu phổ biến; hãy luôn ưu tiên quy chuẩn của khách hàng hoặc của nền tảng đích khi có sẵn, và nêu rõ bạn đã sử dụng bộ giới hạn nào.
## Tốc độ đọc (số ký tự trên giây - characters per second, CPS)
CPS = tổng số ký tự hiển thị nhìn thấy được trong thẻ phụ đề (chữ cái, chữ số, khoảng trắng, dấu câu; không tính các thẻ định dạng HTML/tag) chia cho số giây hiển thị trên màn hình.
| Đối tượng khán giả | Giới hạn CPS điển hình |
|---|---|
| Khán giả trưởng thành nói chung, phụ đề cùng ngôn ngữ hoặc dịch thuật | 15 đến 17 |
| Nội dung người lớn có nhịp độ nhanh nơi bộ quy chuẩn cho phép | tối đa 20 |
| Trẻ em (khoảng từ 6 đến 11 tuổi) | 12 đến 13 |
| Người đang học ngôn ngữ đó, phụ đề SDH có nhiều nhãn mô tả âm thanh | 13 đến 15 |
Số từ trên phút (words per minute - wpm) là thước đo cũ hơn; khoảng 160 đến 180 wpm tương ứng với khoảng 15 đến 17 CPS trong tiếng Anh.
## Độ dài dòng và số lượng dòng
- Từ 37 đến 42 ký tự mỗi dòng đối với hầu hết các ngôn ngữ dùng chữ viết Latinh; 42 là giới hạn hiện đại phổ biến nhất.
- Tối đa 2 dòng trên mỗi thẻ phụ đề (cue). Dòng thứ ba sẽ che khuất hình ảnh và rất khó đọc kịp.
- Sử dụng một dòng khi văn bản nằm gọn trên một dòng; chỉ ngắt thành hai dòng khi thực sự cần thiết.
## Thời lượng hiển thị trên màn hình
- Tối thiểu: khoảng 5/6 giây (20 khung hình ở tốc độ 24 fps), ngay cả đối với một từ đơn lẻ, để mắt người xem kịp ghi nhận.
- Tối đa: khoảng 7 giây. Các thẻ phụ đề hiển thị lâu hơn sẽ khiến người xem đọc đi đọc lại và tạo cảm giác bị treo; hãy tách nhỏ chúng ra.
## Khoảng nghỉ giữa các thẻ phụ đề (Gaps)
- Các thẻ phụ đề nối tiếp (Chained cues): khoảng nghỉ 0 ms hoàn toàn ổn khi lời thoại diễn ra liên tục không ngắt quãng.
- Trong các trường hợp khác, hãy chừa ít nhất 2 khung hình (83 ms ở 24 fps, 80 ms ở 25 fps) để người xem nhận biết được rằng dòng chữ trên màn hình vừa thay đổi.
- Các khoảng nghỉ nằm trong khoảng từ 1 ms đến dưới 2 khung hình sẽ gây ra hiện tượng nhấp nháy chữ rất khó chịu và cần phải được khép kín về 0 hoặc mở rộng ra.
## Ngắt dòng (Line breaks)
Hãy ngắt dòng tại các đơn vị ngôn ngữ tự nhiên:
- Sau dấu câu (dấu phẩy, dấu chấm) khi có thể.
- Trước các liên từ (và, nhưng, bởi vì / and, but, because) và giới từ.
- Tuyệt đối không ngắt dòng giữa mạo từ và danh từ của nó, giữa tính từ và danh từ đi kèm, giữa họ và tên riêng, hoặc giữa động từ chính và trợ động từ.
- Ưu tiên bố cục hình kim tự tháp (dòng trên ngắn hơn dòng dưới) khi cả hai vị trí ngắt dòng đều tự nhiên ngang nhau.
## Đồng bộ thời gian với lời thoại và cảnh quay (shots)
- Bắt đầu hiển thị thẻ phụ đề ngay khi lời thoại bắt đầu (sớm hơn vài khung hình vẫn ổn), và kết thúc không quá khoảng 1 giây sau khi lời thoại dứt.
- Tránh để một thẻ phụ đề kéo dài vắt ngang qua một cú cắt chuyển cảnh cứng (hard shot change); hãy kết thúc phụ đề trước điểm cắt vài khung hình hoặc bắt đầu hiển thị sau điểm cắt.
## Phụ đề SDH và chú thích âm thanh (captions)
- Các nhãn mô tả âm thanh như [tiếng đóng sầm cửa] / [door slams] hoặc (NHẠC) / (MUSIC) và nhãn định danh người nói thuộc về phụ đề SDH/closed captions, không dùng trong phụ đề dịch thuật tiêu chuẩn.
- Giữ cho các nhãn ngắn gọn, viết chữ thường đặt trong ngoặc vuông là phong cách hiện đại phổ biến nhất, và đặt chúng đúng vào thời điểm âm thanh đó phát ra.
## Định dạng (Formatting)
- Chữ in nghiêng dùng cho giọng nói ngoài khung hình (off-screen), giọng thuyết minh (voice-over), lời bài hát và từ ngữ nước ngoài, nếu quy chuẩn có sử dụng. Mọi thẻ mở định dạng đều bắt buộc phải có thẻ đóng tương ứng ngay trong cùng thẻ phụ đề đó.
- Không đặt dấu chấm câu ở cuối các cụm từ đơn lẻ trên một dòng là một lựa chọn về phong cách; hãy tuân theo quy chuẩn của dự án.
- Định dạng SRT: số thứ tự thẻ phụ đề, dòng mốc thời gian `00:01:02,500 --> 00:01:04,000`, nội dung văn bản, dòng trống. Định dạng VTT: dòng tiêu đề `WEBVTT`, mã định danh cue (tùy chọn), mốc thời gian dùng dấu chấm `00:01:02.500`.
FILE:references/condensing-techniques.md
# Kỹ thuật rút gọn văn bản và căn chỉnh lại thời gian phụ đề
Khi một thẻ phụ đề có tốc độ đọc quá nhanh, hãy thử lần lượt các cách dưới đây theo đúng thứ tự. Dừng lại ngay khi thẻ phụ đề đã nằm trong ngưỡng giới hạn cho phép.
## 1. Tận dụng khoảng thời gian trống trước tiên (không thay đổi văn bản)
- Quan sát khoảng trống thời gian phía trước và phía sau thẻ phụ đề đó. Kéo điểm bắt đầu lùi về sớm hơn tối đa khoảng 0.5 giây nếu không có ai khác đang nói, và kéo điểm kết thúc trễ hơn tối đa khoảng 1 giây nếu thẻ phụ đề tiếp theo bắt đầu muộn hơn.
- Giữ khoảng nghỉ tối thiểu (2 khung hình) với các thẻ phụ đề liền kề, hoặc nối liền nhau ở mức 0 ms.
- Thời gian cần thiết cho một thẻ phụ đề = số ký tự / giới hạn CPS. Ví dụ: 68 ký tự ở mức giới hạn 17 CPS cần 4.0 giây hiển thị.
## 2. Cắt bỏ từ đệm, giữ lại nội dung chính
Loại bỏ những từ mà người xem đằng nào cũng đã nghe thấy hoặc nhìn thấy:
- Các từ ngập ngừng và từ đệm: "à thì" ("well"), "bạn biết đấy" ("you know"), "ý tôi là" ("I mean"), "ờm" ("um"), "vậy nên về cơ bản là" ("so, basically").
- Các từ lặp lại: "rất, rất" ("very, very") -> "rất" ("very"); các câu nói nhịu ở đầu câu.
- Lời chào hỏi và tên gọi đã quá rõ ràng thông qua hình ảnh trên màn hình.
- Câu hỏi đuôi khi giọng điệu đã thể hiện rõ ý hỏi: "It's ready, isn't it?" -> "It's ready?" chỉ áp dụng khi ý nghĩa câu không đổi.
## 3. Sử dụng các cụm từ ngắn gọn hơn
| Cụm từ dài | Cụm từ ngắn |
|---|---|
| we are going to | we'll |
| at this point in time | now |
| in order to | to |
| a large number of | many |
| it is not possible to | you can't |
| I would like to show you | let me show you |
| due to the fact that | because |
Dạng viết tắt rút gọn (contractions) thường hoàn toàn phù hợp trong phụ đề; hãy tuân theo quy chuẩn riêng nếu đó là nội dung trang trọng.
## 4. Đơn giản hóa cấu trúc câu
- Chuyển câu bị động thành câu chủ động: "Chiếc bát được làm sạch bởi người nghệ nhân" ("The bowl was cleaned by the artist") -> "Người nghệ nhân làm sạch chiếc bát" ("The artist cleans the bowl").
- Thay thế cả một mệnh đề dài bằng một từ đơn lẻ khi có thể.
- Tách một câu dài thành hai thẻ phụ đề ngắn hơn tại một nhịp nghỉ tự nhiên.
## 5. Tách hoặc gộp các thẻ phụ đề (cues)
- Tách một thẻ phụ đề kéo dài hơn khoảng 7 giây, hoặc có tới dòng thứ 3, tại một nhịp nghỉ hoặc dấu câu; phân bổ thời lượng cho mỗi nửa tỷ lệ thuận với số lượng ký tự của nó.
- Gộp một thẻ phụ đề ngắn dưới khoảng 0.8 giây với thẻ phụ đề liền kề khi cùng một người nói và kết quả sau khi gộp vẫn nằm trong các ngưỡng giới hạn cho phép.
## Những gì tuyệt đối không được cắt bỏ
- Tên riêng, con số, số lượng, ngày tháng, thuật ngữ kỹ thuật và các từ phủ định ("không", "chưa bao giờ" / "not", "never").
- Những từ mang điểm nhấn hài hước (punchline), bước ngoặt bất ngờ hoặc cảm xúc chủ đạo của câu thoại.
- Thông tin mà người xem cần nắm để hiểu các phần sau của video.
## Căn chỉnh lại thời gian cho toàn bộ tệp (Retiming)
Nếu tất cả các thẻ phụ đề đều bị sớm hoặc muộn cùng một khoảng thời gian như nhau, hãy dịch chuyển mốc thời gian của toàn bộ tệp thay vì sửa từng thẻ phụ đề riêng lẻ:
```bash
python3 scripts/check_subtitles.py movie.srt --shift 1500 > movie-shifted.srt # 1.5 s later
python3 scripts/check_subtitles.py movie.srt --shift -800 > movie-shifted.srt # 0.8 s earlier
```
Nếu độ lệch tăng dần theo thời gian (đồng bộ ở đầu video nhưng bị trễ ở cuối video), rất có thể tốc độ khung hình (frame rate) đã bị chọn sai (ví dụ: 23.976 so với 25 fps); việc dịch chuyển một khoảng thời gian cố định sẽ không khắc phục được lỗi này. Hãy nêu rõ điều đó và hỏi người dùng tốc độ khung hình chính xác của video.
FILE:templates/subtitle-review.md
# Đánh giá phụ đề: file_name
**Video:** video_title (duration, fps fps)
**Loại phụ đề:** translation / same-language captions / SDH
**Ngưỡng giới hạn áp dụng:** cps ký tự/giây, line ký tự/dòng, tối đa lines dòng, hiển thị từ min_dur đến max_dur giây trên màn hình, khoảng nghỉ tối thiểu gap ms (source_of_limits)
## Tóm tắt
| | Trước khi sửa | Sau khi sửa |
|---|---|---|
| Số thẻ phụ đề (Cues) | | |
| HIGH | | |
| WARN | | |
| INFO | | |
| Trung bình ký tự/giây | | |
Các vấn đề chính tóm gọn trong một hoặc hai câu.
## Chi tiết các thay đổi
| Thẻ (Cue) | Vấn đề | Thời gian cũ | Thời gian mới | Nội dung cũ | Nội dung mới |
|---|---|---|---|---|---|
| | | | | | |
## Chưa khắc phục (cần nghe âm thanh hoặc xem video)
- Thẻ phụ đề (Cue) ..., lý do ...
## Ghi chú dành cho biên tập viên
- Các lựa chọn về phong cách đã áp dụng (chữ in nghiêng, viết tắt rút gọn, nhãn âm thanh).
- Bất kỳ điểm nào cần đối chiếu trực tiếp với hình ảnh (điểm cắt chuyển cảnh, chữ hiển thị sẵn trên màn hình).
## Tệp đã chỉnh sửa hoàn chỉnh
```
(toàn bộ nội dung tệp SRT hoặc VTT đã sửa đặt tại đây, giữ cùng định dạng với tệp đầu vào)
```
FILE:examples/example-workshop-video.md
# Ví dụ: đánh giá phụ đề của một video hướng dẫn ngắn
## Yêu cầu của người dùng
"Đây là phụ đề tiếng Anh cho đoạn video ngắn dài 25 giây về buổi hướng dẫn nghệ thuật kintsugi của chúng tôi. Người xem phản hồi rằng họ không đọc kịp. Bạn có thể kiểm tra và sửa những chỗ chưa ổn giúp tôi được không? Khán giả trưởng thành nói chung, phụ đề thông thường, không phải SDH."
## Đầu vào (trích đoạn của tệp interview.srt)
```
2
00:00:03,250 --> 00:00:04,000
Today we are going to look at how to repair a cracked ceramic bowl with gold.
3
00:00:03,900 --> 00:00:06,500
[HAMMER TAPPING]
First, clean the edges.
5
00:00:07,000 --> 00:00:16,000
<i>This technique is called kintsugi.
```
## Kết quả chạy trình kiểm tra
```
$ python3 scripts/check_subtitles.py interview.srt
interview.srt (SRT): 8 cues, 00:00:01,000 to 00:00:24,000, avg 16.3 chars/s, avg 2.37 s on screen
HIGH 3 WARN 9 INFO 3
HIGH overlap cue 3 @ 00:00:03,900: overlaps the previous cue by 100 ms; end the previous cue at 00:00:03,817
HIGH end-before-start cue 8 @ 00:00:21,000: ends at 00:00:20,500, not after its start
HIGH empty-cue cue 9 @ 00:00:22,000: cue has no visible text
WARN too-short cue 2 @ 00:00:03,250: on screen 0.75 s (minimum 0.83 s)
WARN reading-speed cue 2 @ 00:00:03,250: 102.7 chars/s (limit 17); needs 4.53 s on screen or about 64 fewer chars
...
```
## Bản đánh giá (bản mẫu đã điền, rút gọn)
**Ngưỡng giới hạn áp dụng:** 17 ký tự/giây, 42 ký tự/dòng, tối đa 2 dòng, 0.833 đến 7 giây, khoảng nghỉ 83 ms (các giá trị tiêu chuẩn phổ biến cho khán giả đại chúng; khách hàng không cung cấp quy chuẩn riêng).
| | Trước khi sửa | Sau khi sửa |
|---|---|---|
| Số thẻ phụ đề (Cues) | 8 | 8 |
| HIGH | 3 | 0 |
| WARN | 9 | 0 |
| INFO | 3 | 0 |
Các vấn đề chính: thẻ phụ đề số 2 chứa cả một câu dài nhưng chỉ hiển thị chưa đầy một giây, hai thẻ phụ đề có mốc thời gian bị lỗi, một thẻ phụ đề dài 9 giây hiển thị quá lâu trên màn hình, và thẻ phụ đề số 7 có tới ba dòng dài. Tất cả đã được khắc phục bằng cách rút gọn từ đệm, tận dụng khoảng im lặng sau lời thoại và tách nhỏ một thẻ phụ đề.
| Thẻ (Cue) | Vấn đề | Thời gian cũ | Thời gian mới | Nội dung cũ | Nội dung mới |
|---|---|---|---|---|---|
| 2 | 102.7 ký tự/giây, dòng 77 ký tự, chồng lấp với thẻ 3 | 03,250-04,000 | 03,300-06,600 | Today we are going to look at how to repair a cracked ceramic bowl with gold. | Today we'll repair a cracked bowl / with lacquer and gold. |
| 3 | chứa nhãn âm thanh trong tệp không phải SDH, chồng lấp | 03,900-06,500 | 06,700-08,200 | [HAMMER TAPPING] / First, clean the edges. | First, clean the edges. |
| 4 (cũ là 5) | hiển thị 9 giây, thẻ in nghiêng chưa đóng, nhảy cóc số thứ tự | 07,000-16,000 | 08,300-11,000 | `<i>`This technique is called kintsugi. | `<i>`This technique is called kintsugi.`</i>` |
| 5 (cũ là 6) | hiển thị 0.4 giây trên màn hình | 16,000-16,400 | 16,000-16,900 | Okay. | Okay. |
| 6 và 7 (cũ là 7) | 56 ký tự/giây, 3 dòng, dòng 67 ký tự | 17,000-19,000 | 17,000-21,000 và 21,100-22,800 | You will need lacquer, a fine brush, and gold powder, and patience, / lots of patience, / because each layer must dry. | You'll need lacquer, a fine brush, / gold powder, and lots of patience. + Each layer must dry first. |
| 8 (cũ là 8) | thời điểm kết thúc đứng trước bắt đầu | 21,000-20,500 | 22,900-24,000 | Let's start. | Let's start. |
| 9 | thẻ phụ đề trống | 22,000-24,000 | đã xóa | | |
Lưu ý: thẻ phụ đề số 2 cũ được rút gọn ("look at how to", "ceramic") chỉ ở những chi tiết mà hình ảnh đã thể hiện rõ; từ "lacquer" (sơn mài) được bổ sung vì người nói nhắc đến nó vài giây sau đó và người xem cần thông tin này.
**Chưa khắc phục:** không có, nhưng vui lòng đối chiếu lại với video để xác nhận rằng người nói có ngừng nghỉ sau từ "gold" (thẻ phụ đề số 2 hiện kéo dài thêm 2.6 giây, lấn vào khoảng có vẻ là im lặng) và câu "Let's start." được nói vào khoảng giây thứ 22.9.
Kiểm tra lại tệp đã chỉnh sửa:
```
$ python3 scripts/check_subtitles.py interview-fixed.srt
interview-fixed.srt (SRT): 8 cues, 00:00:01,000 to 00:00:24,000, avg 14.5 chars/s, avg 2.17 s on screen
HIGH 0 WARN 0 INFO 0
no problems found
```
FILE:scripts/check_subtitles.py
#!/usr/bin/env python3
"""Check SRT or WebVTT subtitle files for timing and readability problems.
Usage:
python3 check_subtitles.py FILE [FILE ...] [options]
python3 check_subtitles.py - < movie.srt (read stdin)
Options:
--max-cps N maximum characters per second (default 17)
--max-line N maximum characters per line (default 42)
--max-lines N maximum lines per cue (default 2)
--min-dur S minimum cue duration in seconds (default 0.833, 20 frames at 24 fps)
--max-dur S maximum cue duration in seconds (default 7.0)
--min-gap MS minimum gap between cues in milliseconds (default 83, 2 frames at 24 fps)
--shift MS print the file with every timestamp shifted by MS (may be negative) and exit
--json print findings as JSON
--fail-on LEVEL exit 1 if any finding is at least LEVEL: high (default), warn, info
Findings: HIGH = broken or overlapping timing, unreadable numbering, empty cue;
WARN = reading speed, line length, too many lines, too short or long on screen;
INFO = tiny gaps, unbalanced tags, hearing-impaired brackets, statistics.
Exit codes: 0 ok, 1 findings at or above --fail-on, 2 usage or parse error.
Python 3 standard library only.
"""
import json
import math
import re
import sys
TS = re.compile(r"^\s*(\d{1,2}:)?(\d{1,2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}:)?(\d{1,2}):(\d{2})[,.](\d{1,3})(.*)$")
TAG = re.compile(r"</?[^>]+>|\{\\[^}]*\}")
LEVELS = {"info": 0, "warn": 1, "high": 2}
def to_ms(h, m, s, ms):
h = int(h[:-1]) if h else 0
return ((h * 60 + int(m)) * 60 + int(s)) * 1000 + int(ms.ljust(3, "0"))
def fmt(ms, vtt=False):
sign = "-" if ms < 0 else ""
ms = abs(ms)
h, rem = divmod(ms, 3600000)
m, rem = divmod(rem, 60000)
s, rem = divmod(rem, 1000)
sep = "." if vtt else ","
return f"{sign}{h:02d}:{m:02d}:{s:02d}{sep}{rem:03d}"
def parse(text):
"""Return (cues, errors, is_vtt). Each cue: dict(index, start, end, lines, line_no)."""
text = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
is_vtt = text.lstrip().startswith("WEBVTT")
blocks = re.split(r"\n\s*\n", text.strip("\n"))
cues, errors = [], []
line_no = 1
for block in blocks:
lines = block.split("\n")
start_line = line_no
line_no += len(lines) + 1
if is_vtt and (lines[0].startswith("WEBVTT") or lines[0].startswith("NOTE") or lines[0].startswith("STYLE") or lines[0].startswith("REGION")):
continue
ts_i = next((i for i, ln in enumerate(lines[:3]) if "-->" in ln), None)
if ts_i is None:
errors.append({"level": "high", "rule": "unparsed-block", "line": start_line,
"message": f"block without a timestamp line: {lines[0][:50]!r}"})
continue
m = TS.match(lines[ts_i])
if not m:
errors.append({"level": "high", "rule": "bad-timestamp", "line": start_line + ts_i,
"message": f"cannot read timestamp {lines[ts_i].strip()!r} (expected 00:01:02,500 --> 00:01:04,000)"})
continue
g = m.groups()
index = lines[0].strip() if ts_i == 1 else None
cues.append({"index": index, "start": to_ms(*g[0:4]), "end": to_ms(*g[4:8]),
"lines": [ln for ln in lines[ts_i + 1:]], "line_no": start_line})
return cues, errors, is_vtt
def visible(line):
return TAG.sub("", line).strip()
def check(cues, errors, opts, is_vtt):
out = list(errors)
def add(level, rule, cue, msg):
out.append({"level": level, "rule": rule, "line": cue["line_no"],
"cue": cue["index"] or "-", "time": fmt(cue["start"], is_vtt), "message": msg})
if not is_vtt:
expected = 1
for c in cues:
if c["index"] is None or not c["index"].isdigit():
add("high", "bad-number", c, f"cue number missing or not a number: {c['index']!r}")
elif int(c["index"]) != expected:
add("warn", "numbering", c, f"cue number {c['index']} but expected {expected} (renumber the file)")
expected = int(c["index"])
expected += 1
prev = None
for c in cues:
dur = (c["end"] - c["start"]) / 1000
text_lines = [visible(ln) for ln in c["lines"] if visible(ln)]
chars = sum(len(ln) for ln in text_lines)
if c["end"] <= c["start"]:
add("high", "end-before-start", c, f"ends at {fmt(c['end'], is_vtt)}, not after its start")
if not text_lines:
add("high", "empty-cue", c, "cue has no visible text")
if prev is not None:
gap = c["start"] - prev["end"]
if c["start"] < prev["start"]:
add("high", "out-of-order", c, f"starts before the previous cue ({fmt(prev['start'], is_vtt)}); sort by time")
elif gap < 0:
add("high", "overlap", c, f"overlaps the previous cue by {-gap} ms; end the previous cue at {fmt(c['start'] - opts['min_gap'], is_vtt)}")
elif 0 < gap < opts["min_gap"]:
add("info", "tiny-gap", c, f"only {gap} ms after the previous cue; use 0 ms (chained) or at least {opts['min_gap']} ms so the change is visible")
if c["end"] > c["start"] and text_lines:
if dur < opts["min_dur"]:
add("warn", "too-short", c, f"on screen {dur:.2f} s (minimum {opts['min_dur']:.2f} s)")
if dur > opts["max_dur"]:
add("warn", "too-long", c, f"on screen {dur:.2f} s (maximum {opts['max_dur']:.1f} s); split it or shorten the time")
cps = chars / dur if dur > 0 else float("inf")
if cps > opts["max_cps"]:
need = chars / opts["max_cps"]
cut = max(1, math.ceil(chars - opts["max_cps"] * dur))
add("warn", "reading-speed", c, f"{cps:.1f} chars/s (limit {opts['max_cps']:g}); needs {need:.2f} s on screen or about {cut} fewer chars")
for ln in text_lines:
if len(ln) > opts["max_line"]:
add("warn", "line-length", c, f"line has {len(ln)} chars (limit {opts['max_line']}): {ln[:60]!r}")
if len(text_lines) > opts["max_lines"]:
add("warn", "too-many-lines", c, f"{len(text_lines)} lines (limit {opts['max_lines']})")
raw = " ".join(c["lines"])
for tag in ("i", "b", "u"):
if len(re.findall(rf"<{tag}>", raw, re.I)) != len(re.findall(rf"</{tag}>", raw, re.I)):
add("info", "unbalanced-tag", c, f"<{tag}> tags are not balanced in this cue")
if re.search(r"\[[^\]]+\]|\([A-Z][A-Z ]+\)", raw):
add("info", "sdh-label", c, "contains a sound or speaker label in brackets; keep it only in SDH/CC files")
prev = c
return out
def stats(cues, is_vtt=False):
if not cues:
return {}
total_chars = sum(len(visible(ln)) for c in cues for ln in c["lines"])
total_time = sum(max(c["end"] - c["start"], 0) for c in cues) / 1000
return {"cues": len(cues), "first": fmt(cues[0]["start"], is_vtt), "last_end": fmt(max(c["end"] for c in cues), is_vtt),
"avg_cps": round(total_chars / total_time, 1) if total_time else None,
"avg_duration_s": round(total_time / len(cues), 2)}
def shift_text(text, delta):
def rep(m):
g = m.groups()
vtt = "." in m.group(0).split("-->")[0]
a = max(to_ms(*g[0:4]) + delta, 0)
b = max(to_ms(*g[4:8]) + delta, 0)
return f"{fmt(a, vtt)} --> {fmt(b, vtt)}{g[8]}"
return "\n".join(TS.sub(rep, ln) if "-->" in ln else ln
for ln in text.replace("\r\n", "\n").split("\n"))
def main(argv):
opts = {"max_cps": 17.0, "max_line": 42, "max_lines": 2, "min_dur": 0.833, "max_dur": 7.0,
"min_gap": 83, "json": False, "fail_on": "high", "shift": None}
files = []
it = iter(argv)
try:
for a in it:
if a == "--json":
opts["json"] = True
elif a in ("--max-cps", "--min-dur", "--max-dur"):
opts[a[2:].replace("-", "_")] = float(next(it))
elif a in ("--max-line", "--max-lines", "--min-gap", "--shift"):
opts[a[2:].replace("-", "_")] = int(next(it))
elif a == "--fail-on":
opts["fail_on"] = next(it).lower()
if opts["fail_on"] not in LEVELS:
raise ValueError("--fail-on must be high, warn, or info")
elif a in ("-h", "--help"):
print(__doc__)
return 0
elif a.startswith("--"):
raise ValueError(f"unknown option {a}")
else:
files.append(a)
except (StopIteration, ValueError) as e:
print(f"error: {e or 'option needs a value'}", file=sys.stderr)
return 2
if not files:
print("error: give at least one .srt or .vtt file, or - for stdin", file=sys.stderr)
return 2
report, worst = [], -1
for f in files:
try:
text = sys.stdin.read() if f == "-" else open(f, encoding="utf-8-sig").read()
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {f}: {e}", file=sys.stderr)
return 2
if opts["shift"] is not None:
sys.stdout.write(shift_text(text, opts["shift"]))
return 0
cues, errors, is_vtt = parse(text)
if not cues:
print(f"error: {f}: no subtitle cues found (is this an SRT or WebVTT file?)", file=sys.stderr)
return 2
findings = check(cues, errors, opts, is_vtt)
for x in findings:
worst = max(worst, LEVELS[x["level"]])
report.append({"file": f, "format": "vtt" if is_vtt else "srt", "stats": stats(cues, is_vtt), "findings": findings})
if opts["json"]:
print(json.dumps(report, indent=2))
else:
for r in report:
counts = {lv: sum(1 for x in r["findings"] if x["level"] == lv) for lv in ("high", "warn", "info")}
st = r["stats"]
print(f"{r['file']} ({r['format'].upper()}): {st['cues']} cues, {st['first']} to {st['last_end']}, "
f"avg {st['avg_cps']} chars/s, avg {st['avg_duration_s']} s on screen")
print(f" HIGH {counts['high']} WARN {counts['warn']} INFO {counts['info']}")
for x in sorted(r["findings"], key=lambda x: (-LEVELS[x["level"]], x["line"])):
where = f"cue {x.get('cue', '-')} @ {x['time']}" if "time" in x else f"line {x['line']}"
print(f" {x['level'].upper():<4} {x['rule']:<16} {where}: {x['message']}")
if not r["findings"]:
print(" no problems found")
return 1 if worst >= LEVELS[opts["fail_on"]] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))