Skip to content

Commit

Permalink
fix: allow ARIA headings in EDUPUB (legacy profile)
Browse files Browse the repository at this point in the history
This commit fixes the legacy EDUPUB checks by allowing ARIA headings along with native h1-h6 headings.

Note: EDUPUB support is a legacy feature of EPUBCheck, since the standard is not actively maintained.

Fix #1483
  • Loading branch information
rdeltour committed Apr 21, 2023
1 parent 199da0b commit da4f541
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,92 +2,148 @@
<schema xmlns="http://purl.oclc.org/dsdl/schematron" queryBinding="xslt2">
<ns uri="http://www.idpf.org/2007/ops" prefix="epub"/>
<ns uri="http://www.w3.org/1999/xhtml" prefix="html"/>

<!-- following variable declarations are used to test h# nesting depth -->
<!-- checks if the body contains anything other than a single section or article - i.e., is it an implied section
- previously tested if >1 article/section children with : or count(//html:body/child::html:*[self::html:article or self::html:section]) &gt; 1
but ambiguous whether multiple section elements is an implied body or just a weird breakup of the file
-->
<let name="body-is-section" value="exists(//html:body/(html:* except (html:article|html:section)))"/>

<let name="body-is-section"
value="exists(//html:body/(html:* except (html:article | html:section)))"/>

<!-- check if implied heading -->
<let name="body-label-len" value="string-length(normalize-space(//html:body/@aria-label))"/>

<!-- finds the topmost heading in the file that is the descendant of the body (not sectioning element ancestors) or the first descendant of a section or article -->
<let name="topmost-heading" value="//html:body//(html:h1|html:h2|html:h3|html:h4|html:h5|html:h6)[not(ancestor::html:aside|ancestor::html:nav) and count(ancestor::html:section|ancestor::html:article) le 1]"/>

<let name="topmost-heading"
value="//html:body//(html:h1 | html:h2 | html:h3 | html:h4 | html:h5 | html:h6 | html:*[@role = 'heading'])[not(ancestor::html:aside | ancestor::html:nav) and count(ancestor::html:section | ancestor::html:article) le 1]"/>

<!-- extract the starting rank from the topmost-heading -->
<let name="topmost-heading-rank" value="if ($body-label-len &gt; 0) then 1 else if (exists($topmost-heading)) then number(substring(name($topmost-heading[1]),2)) else 1"/>

<let name="topmost-heading-rank" value="
if ($body-label-len &gt; 0) then
1
else
if (exists($topmost-heading)) then
if ($topmost-heading[1][@role = 'heading']) then
if ($topmost-heading[1]/@aria-level) then
number($topmost-heading[1]/@aria-level)
else
2
else
number(substring(name($topmost-heading[1]), 2))
else
1"/>

<!-- find the nesting depth of the topmost heading (0 if body, 1 if a section or article around it) -->
<let name="topmost-heading-nest" value="if ($body-label-len &gt; 0) then 0 else if (empty($topmost-heading[1]/(ancestor::html:section|ancestor::html:article|ancestor::html:nav))) then 0 else 1"/>


<let name="topmost-heading-nest" value="
if ($body-label-len &gt; 0) then
0
else
if (empty($topmost-heading[1]/(ancestor::html:section | ancestor::html:article | ancestor::html:nav))) then
0
else
1"/>


<pattern id="edupub.headings">
<rule context="html:body[html:* except (html:article|html:section|html:aside|html:nav)]">
<let name="headings" value=".//(html:h1|html:h2|html:h3|html:h4|html:h5|html:h6)[empty(ancestor::html:section|ancestor::html:aside|ancestor::html:article|ancestor::html:nav)]"/>

<rule context="html:body[html:* except (html:article | html:section | html:aside | html:nav)]">
<let name="headings"
value=".//(html:h1 | html:h2 | html:h3 | html:h4 | html:h5 | html:h6 | html:*[@role = 'heading'])[empty(ancestor::html:section | ancestor::html:aside | ancestor::html:article | ancestor::html:nav)]"/>

<report test="@aria-label and $body-label-len = 0">Empty aria-label attribute found.</report>

<assert test="$body-label-len &gt; 0 or count($headings) &gt; 0">The body element requires a heading when it is used as an implied section.</assert>


<assert test="$body-label-len &gt; 0 or count($headings) &gt; 0">The body element requires a
heading when it is used as an implied section.</assert>

<!-- <report test="$arialabel-len &gt; 0 and count($headings) &gt; 0">The aria-label attribute must not be mixed with ranked headings.</report> -->

<report test="count($headings) &gt; 1">More than one ranked heading found as direct descendant of body.</report>

<report test="count($headings) = 1 and string-length(normalize-space(string-join($headings|$headings/html:img/@alt|$headings//@aria-label))) = 0">Empty ranked heading detected.</report>

<report test="@aria-label and (normalize-space($headings) = normalize-space(@aria-label))">The value of the "aria-label" attribute must not be the same as the content of the heading.</report>

<report test="count($headings) &gt; 1">More than one ranked heading found as direct descendant
of body.</report>

<report
test="count($headings) = 1 and string-length(normalize-space(string-join($headings | $headings/html:img/@alt | $headings//@aria-label))) = 0"
>Empty ranked heading detected.</report>

<report test="@aria-label and (normalize-space($headings) = normalize-space(@aria-label))">The
value of the "aria-label" attribute must not be the same as the content of the
heading.</report>
</rule>
<rule context="html:section|html:article">


<rule context="html:section | html:article">
<let name="arialabel-len" value="string-length(normalize-space(@aria-label))"/>
<let name="headings" value=".//(html:h1|html:h2|html:h3|html:h4|html:h5|html:h6)[(ancestor::html:section|ancestor::html:article|ancestor::html:aside|ancestor::html:nav)[last()] = current()]"/>

<let name="headings"
value=".//(html:h1 | html:h2 | html:h3 | html:h4 | html:h5 | html:h6 | html:*[@role = 'heading'])[(ancestor::html:section | ancestor::html:article | ancestor::html:aside | ancestor::html:nav)[last()] = current()]"/>

<report test="@aria-label and $arialabel-len = 0">Empty aria-label attribute found.</report>

<assert test="$arialabel-len &gt; 0 or count($headings) &gt; 0"><value-of select="name()"/> does not have a heading.</assert>


<assert test="$arialabel-len &gt; 0 or count($headings) &gt; 0"><value-of select="name()"/>
does not have a heading.</assert>

<!-- <report test="$arialabel-len &gt; 0 and count($headings) &gt; 0">The aria-label attribute must not be mixed with ranked headings.</report> -->

<report test="count($headings) &gt; 1">More than one ranked heading found as direct descendant of <value-of select="name()"/>.</report>

<report test="count($headings) = 1 and string-length(normalize-space(string-join($headings|$headings/html:img/@alt|$headings//@aria-label))) = 0">Empty ranked heading detected.</report>

<report test="@aria-label and (normalize-space($headings) = normalize-space(@aria-label))">The value of the "aria-label" attribute must not be the same as the content of the heading.</report>

<report test="count($headings) &gt; 1">More than one ranked heading found as direct descendant
of <value-of select="name()"/>.</report>

<report
test="count($headings) = 1 and string-length(normalize-space(string-join($headings | $headings/html:img/@alt | $headings//@aria-label))) = 0"
>Empty ranked heading detected.</report>

<report test="@aria-label and (normalize-space($headings) = normalize-space(@aria-label))">The
value of the "aria-label" attribute must not be the same as the content of the
heading.</report>
</rule>

<rule context="html:h1|html:h2|html:h3|html:h4|html:h5|html:h6">

<rule
context="html:h1 | html:h2 | html:h3 | html:h4 | html:h5 | html:h6 | html:*[@role = 'heading']">
<!-- get the # from the h# tag found -->
<let name="current-rank" value="number(substring(name(current()),2))"/>

<let name="current-rank" value="
if (current()[@role = 'heading']) then
if (current()/@aria-level) then
number(current()/@aria-level)
else
2
else
number(substring(name(current()), 2))"/>

<!-- find nesting depth -->
<let name="current-nesting" value="count(ancestor::html:section|ancestor::html:article|ancestor::html:aside|ancestor::html:nav)"/>

<let name="current-nesting"
value="count(ancestor::html:section | ancestor::html:article | ancestor::html:aside | ancestor::html:nav)"/>

<!-- derive the expected rank of this heading from the implied body or sectioning -->
<let name="expected-rank" value="if ($body-is-section) then $topmost-heading-rank - $topmost-heading-nest + $current-nesting else $topmost-heading-rank + $current-nesting - 1"/>

<let name="expected-rank" value="
if ($body-is-section) then
$topmost-heading-rank - $topmost-heading-nest + $current-nesting
else
$topmost-heading-rank + $current-nesting - 1"/>

<!-- report ranked headings in sectioning roots -->
<report test="ancestor::html:figure or ancestor::html:blockquote">Ranked headings are not valid in figure or blockquote</report>

<report test="ancestor::html:figure or ancestor::html:blockquote">Ranked headings are not
valid in figure or blockquote</report>

<!-- if the expected rank is below 6, check that it matches what is expected -->
<report test="$expected-rank &lt; 6 and not($current-rank = $expected-rank)">The heading rank h<value-of select="$current-rank"/> does not match the current nesting level (<value-of select="$expected-rank"/>).</report>

<report test="$expected-rank &lt; 6 and not($current-rank = $expected-rank)">The heading rank
h<value-of select="$current-rank"/> does not match the current nesting level (<value-of
select="$expected-rank"/>).</report>

<!-- otherwise, just stop testing after 5 and report any headings that aren't six, since no higher exist -->
<report test="$expected-rank &gt; 5 and $current-rank &lt; 6">The current heading rank should be h6.</report>
<report test="$expected-rank &gt; 5 and $current-rank &lt; 6">The current heading rank should
be h6.</report>
</rule>
</pattern>

<pattern id="edupub.sectioning">
<rule context="*[parent::html:body or parent::html:section][not(self::html:section)]">
<report test="preceding-sibling::html:section">Non-section elements not allowed between or after section elements.</report>
<report test="preceding-sibling::html:section">Non-section elements not allowed between or
after section elements.</report>
</rule>
</pattern>

<pattern id="edupub.subtitles">
<rule context="html:p[@epub:type='subtitle'][preceding-sibling::*[self::html:h1|self::html:h2|self::html:h3|self::html:h4|self::html:h5|self::html:h6]]">
<assert test="ancestor::html:header">Section subtitles must be wrapped in a header element.</assert>
<rule
context="html:p[@epub:type = 'subtitle'][preceding-sibling::*[self::html:h1 | self::html:h2 | self::html:h3 | self::html:h4 | self::html:h5 | self::html:h6]]">
<assert test="ancestor::html:header">Section subtitles must be wrapped in a header
element.</assert>
</rule>
</pattern>
</schema>
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ Feature: EPUB for Education ▸ XHTML Content Document Checks
And the message contains 'The body element requires a heading when it is used as an implied section'
And no other errors or warnings are reported

Scenario: Report a missing section heading
When checking document 'edupub-heading-missing-error.xhtml'
Then error RSC-005 is reported
And the message contains 'section does not have a heading'
But no other errors or warnings are reported

Scenario: Allow a section heading specified as ARIA heading role
When checking document 'edupub-heading-aria-role-valid.xhtml'
Then no errors or warnings are reported

Scenario: Verify a heading with only an `img` that has alternative text
When checking document 'edupub-heading-img-alt-valid.xhtml'
Then no errors or warnings are reported

Scenario: Report a heading with only an `img` without alternative text
When checking document 'edupub-heading-img-no-alt-error.xhtml'
Then error RSC-005 is reported
And the message contains 'Empty ranked heading detected'
And no other errors or warnings are reported

## 4.3 Titles and Headings

Expand Down Expand Up @@ -84,15 +103,3 @@ Feature: EPUB for Education ▸ XHTML Content Document Checks
And no other errors or warnings are reported


# No matching section

Scenario: Verify a heading with only an `img` that has alternative text
When checking document 'edupub-heading-img-alt-valid.xhtml'
Then no errors or warnings are reported

Scenario: Report a heading with only an `img` without alternative text
When checking document 'edupub-heading-img-no-alt-error.xhtml'
Then error RSC-005 is reported
And the message contains 'Empty ranked heading detected'
And no other errors or warnings are reported

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta charset="UTF-8" />
</head>
<body>
<section>
<span aria-level="1" role="heading">Heading</span>
</section>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Test</title>
<meta charset="UTF-8" />
</head>
<body>
<section>
<!--<h1>Test</h1>-->
</section>
</body>
</html>

0 comments on commit da4f541

Please sign in to comment.