Skip to content
Merged
3 changes: 3 additions & 0 deletions pkg/content/element/attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package element
type AttributeKey string

const AcessibilityLabelAttributeKey AttributeKey = "accessibilityLabel"
const AccessibilityDetailsAttributeKey AttributeKey = "accessibilityDetails"
const AccessibilityLabeledByAttributeKey AttributeKey = "accessibilityLabeledBy"
const AccessibilityDescribedByAttributeKey AttributeKey = "accessibilityDescribedBy"
const LanguageAttributeKey AttributeKey = "language"

// An attribute is an arbitrary key-value metadata pair.
Expand Down
2 changes: 1 addition & 1 deletion pkg/content/element/element.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func (e AudioElement) MarshalJSON() ([]byte, error) {
res := ElementToMap(e)
res["text"] = e.Text()
res["link"] = e.EmbeddedLink()
res["@type"] = "Video"
res["@type"] = "Audio"
return json.Marshal(res)
}

Expand Down
1 change: 1 addition & 0 deletions pkg/content/iterator/html_converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ func (c *HTMLConverter) flushText() {
if len(c.breadcrumbs) > 0 {
el := c.breadcrumbs[len(c.breadcrumbs)-1].node
for _, at := range el.Attr {
// THIS IS WRONG! need epub:type so split the str
if at.Namespace == "http://www.idpf.org/2007/ops" && at.Key == "type" && at.Val == "footnote" {
bestRole = element.Footnote{}
break
Expand Down
196 changes: 196 additions & 0 deletions pkg/guidednavigation/converter/a11y.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package converter

import (
"encoding/xml"
"slices"
"strings"

"github.com/readium/go-toolkit/pkg/guidednavigation"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)

func getElementByID(n *html.Node, id string) *html.Node {
if n.Type == html.ElementNode {
for _, a := range n.Attr {
if a.Key == "id" && a.Val == id {
return n
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if res := getElementByID(c, id); res != nil {
return res
}
}
return nil
}

func nodeIsHidden(n *html.Node) bool {
for _, attr := range n.Attr {
if attr.Key == "aria-hidden" && attr.Val == "true" {
return true
}
if attr.Key == "hidden" {
return true
}
}
return false
}

func nodeText(sb *strings.Builder, n *html.Node) {
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.TextNode {
sb.WriteString(n.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(n)
}

// Normalized (whitespace-coalesced and trimmed) text content of a node's subtree.
func normalizedNodeText(n *html.Node) string {
var raw strings.Builder
nodeText(&raw, n)
var sb strings.Builder
appendNormalizedWhitespace(&sb, raw.String(), true)
return strings.TrimSpace(sb.String())
}

// https://www.w3.org/TR/accname/#terminology
// Returns the node's accessibility text if existent, and whether or not the node is visible in the first place.
func ExtractNodeAria(el *html.Node) (*guidednavigation.GuidedNavigationText, bool) {
// 2.A
if nodeIsHidden(el) {
return nil, false
}

// 2.B
if labelledBy := strings.TrimSpace(getAttr(el, "aria-labelledby")); labelledBy != "" {
rawIds := strings.Fields(labelledBy)
ids := make([]string, 0, len(rawIds))
for _, v := range rawIds {
if v != "" && !slices.Contains(ids, v) {
ids = append(ids, v)
}
}

// Traverse up to the root of the document
doc := el
for doc.Parent != nil {
doc = doc.Parent
}

labelNodes := make([]*html.Node, 0, len(ids))
for _, v := range ids {
n := getElementByID(doc, v)
if n != nil {
labelNodes = append(labelNodes, n)
}
}
if len(labelNodes) > 0 {
var sb strings.Builder
for i, n := range labelNodes {
if nodeIsHidden(n) {
continue
}
if label := getAttr(n, "aria-label"); label != "" {
sb.WriteString(label)
} else {
nodeText(&sb, n)
}

if i < len(labelNodes)-1 {
sb.WriteRune(' ') // Add a space at the end
}
}
var normalized strings.Builder
appendNormalizedWhitespace(&normalized, sb.String(), true)
text := strings.TrimSpace(normalized.String())
if text != "" {
return &guidednavigation.GuidedNavigationText{
Plain: text,
}, true
}
}
}

// 2.C
if label := strings.TrimSpace(getAttr(el, "aria-label")); label != "" {
return &guidednavigation.GuidedNavigationText{
Plain: label,
}, true
}

// 2.D
// TODO: more support for els
switch el.DataAtom {
case atom.Img:
if alt := strings.TrimSpace(getAttr(el, "alt")); alt != "" {
return &guidednavigation.GuidedNavigationText{
Plain: alt,
}, true
}
// 2.I fallback for images: the title attribute
if title := strings.TrimSpace(getAttr(el, "title")); title != "" {
return &guidednavigation.GuidedNavigationText{
Plain: title,
}, true
}
case atom.Svg:
// The accessible name of an SVG comes from its <title> child
if title := childOfType(el, atom.Title, true); title != nil {
if text := normalizedNodeText(title); text != "" {
return &guidednavigation.GuidedNavigationText{
Plain: text,
}, true
}
}
}

return nil, true
}

// ConvertElementToSSMLTag maps an HTML element to the SSML tag its text should be wrapped in.
// https://www.w3.org/TR/speech-synthesis11/#S3.2.2
func ConvertElementToSSMLTag(a atom.Atom) (string, []xml.Attr) {
switch a {
case atom.Em:
return "emphasis", nil
case atom.B:
return "emphasis", nil
case atom.I:
return "emphasis", []xml.Attr{{
Name: xml.Name{Local: "level"},
Value: "reduced",
}}
case atom.Strong:
return "emphasis", []xml.Attr{{
Name: xml.Name{Local: "level"},
Value: "strong",
}}
case atom.Br:
return "break", nil
default:
return "", nil
}
}

// Elements whose entire subtree carries no user-facing content.
var skippedElements = map[atom.Atom]struct{}{
atom.Script: {},
atom.Style: {},
atom.Template: {},
atom.Noscript: {},
atom.Textarea: {},
atom.Select: {},
atom.Datalist: {},
atom.Iframe: {},
// Ruby annotations would duplicate the base text when read aloud
atom.Rt: {},
atom.Rp: {},
atom.Rtc: {},
}
57 changes: 57 additions & 0 deletions pkg/guidednavigation/converter/converter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package converter

import (
"context"
"strings"

"github.com/pkg/errors"
"github.com/readium/go-toolkit/pkg/fetcher"
"github.com/readium/go-toolkit/pkg/guidednavigation"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)

func Do(ctx context.Context, resource fetcher.Resource, locator manifest.Locator) (*guidednavigation.GuidedNavigationDocument, error) {
raw, rerr := fetcher.ReadResourceAsString(ctx, resource)
if rerr != nil {
return nil, errors.Wrap(rerr, "failed reading HTML string of "+resource.Link().Href.String())
}

var document *html.Node
xmlParsed := false
if mt := resource.Link().MediaType; mt != nil && mt.Matches(&mediatype.XHTML) {
// XHTML is XML: parse it as such, so that e.g. self-closing elements are
// handled correctly. Ill-formed documents fall back to the HTML parser.
if doc, err := ParseXHTML(strings.NewReader(raw)); err == nil && childOfType(doc, atom.Body, true) != nil {
document = doc
xmlParsed = true
}
}
if document == nil {
var err error
document, err = html.ParseWithOptions(
strings.NewReader(raw),
html.ParseOptionEnableScripting(false),
)
if err != nil {
return nil, errors.Wrap(err, "failed parsing HTML of "+resource.Link().Href.String())
}
}

body := childOfType(document, atom.Body, true)
if body == nil {
return nil, errors.New("HTML of " + resource.Link().Href.String() + " doesn't have a <body>")
}

contentConverter := NewHTMLConverter(locator)
contentConverter.xmlParsed = xmlParsed

// Traverse the document's HTML
contentConverter.Convert(body)

return &guidednavigation.GuidedNavigationDocument{
Guided: contentConverter.Result(),
}, nil
}
Loading