diff --git a/README.md b/README.md index 154dbe2..988cfee 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,12 @@ myPlaintext, err := textplain.Convert(myHTML, | `WithLinks(style)` | `LinksInline` | | `WithPlainHeadings()` | off, so headings are drawn with rule characters | | `WithMarkdown()` | off; renders CommonMark instead of plain text | +| `WithHiddenContent()` | off, so content hidden by inline styles or attributes is dropped | Later options win, so a caller can layer its own on top of a shared set. +Hidden content is judged from inline styles, so a layout can occasionally convert to an empty string. Callers that need text can retry with `WithHiddenContent()`. + `WithLinks` takes one of three styles: | style | `Docs` becomes | diff --git a/options.go b/options.go index 7180fcf..8a7a662 100644 --- a/options.go +++ b/options.go @@ -26,6 +26,7 @@ type options struct { links LinkStyle plainHeadings bool markdown bool + hiddenContent bool } func newOptions(opts []Option) options { @@ -74,6 +75,12 @@ func WithPlainHeadings() Option { return func(o *options) { o.plainHeadings = true } } +// WithHiddenContent keeps content that inline styles or attributes hide, such +// as preheaders. +func WithHiddenContent() Option { + return func(o *options) { o.hiddenContent = true } +} + // WithMarkdown renders CommonMark instead of plain text. Output is not wrapped, // so WithLineLength and WithPlainHeadings have no effect, and any WithBullet or // WithOrderedSuffix must be a Markdown list marker. diff --git a/options_test.go b/options_test.go index 81ec994..045adad 100644 --- a/options_test.go +++ b/options_test.go @@ -82,6 +82,18 @@ func TestOptionsDefaultToTheDefaultLineLength(t *testing.T) { } } +func TestOptionsHiddenContent(t *testing.T) { + body := `

only content

` + + result, err := textplain.Convert(body) + require.NoError(t, err) + assert.Empty(t, result) + + result, err = textplain.Convert(body, textplain.WithHiddenContent()) + require.NoError(t, err) + assert.Equal(t, "only content", result) +} + func TestOptionsFootnotesSkipEmptyLinks(t *testing.T) { result, err := textplain.Convert(`

text

`, textplain.WithLinks(textplain.LinksFootnotes)) require.NoError(t, err) diff --git a/textplain_test.go b/textplain_test.go index 4e4ed73..ffdd087 100644 --- a/textplain_test.go +++ b/textplain_test.go @@ -951,13 +951,8 @@ func TestHiddenContent(t *testing.T) { expect: "shown", }, testCase{ - name: "zero font size with a unit", - body: `

shown

hidden

`, - expect: "shown", - }, - testCase{ - name: "zero max height", - body: `

shown

hidden

`, + name: "zero max height with overflow hidden", + body: `

shown

hidden

`, expect: "shown", }, testCase{ @@ -1001,6 +996,16 @@ func TestHiddenContent(t *testing.T) { body: `

shown

still visible

`, expect: "shown\n\nstill visible", }, + testCase{ + name: "zero font size container with sized children stays visible", + body: `
still visible
`, + expect: "still visible", + }, + testCase{ + name: "zero max height without overflow hidden stays visible", + body: `

still visible

`, + expect: "still visible", + }, testCase{ name: "aria-hidden false stays visible", body: `

shown

still visible

`, diff --git a/tree.go b/tree.go index 45b1533..4b69c17 100644 --- a/tree.go +++ b/tree.go @@ -65,6 +65,10 @@ type conversion struct { // Convert renders the body of document as plain text, wrapping at // DefaultLineLength unless an option says otherwise. It returns // ErrBodyNotFound if the document has no body. +// +// The result is empty when the body has no visible text, which can mean +// hidden content was misjudged; callers that need text can retry with +// WithHiddenContent. func Convert(document string, opts ...Option) (string, error) { return ConvertReader(strings.NewReader(document), opts...) } @@ -77,13 +81,13 @@ func ConvertReader(r io.Reader, opts ...Option) (string, error) { return "", err } - cv := &conversion{opts: newOptions(opts)} - body := findBody(root) if body == nil { return "", ErrBodyNotFound } + cv := &conversion{opts: newOptions(opts)} + var o output cv.doConvert(&o, body) @@ -190,7 +194,7 @@ func (cv *conversion) doConvert(o *output, n *html.Node) { continue } - if isHidden(c) { + if cv.isHidden(c) { continue } @@ -730,7 +734,7 @@ func (cv *conversion) listItems(o *output, n *html.Node, prefixer func(int) stri idx := listStart(n) for c := n.FirstChild; c != nil; c = c.NextSibling { - if c.Type == html.ElementNode && isHidden(c) { + if c.Type == html.ElementNode && cv.isHidden(c) { continue } @@ -789,7 +793,7 @@ func (cv *conversion) wrapSpans(o *output, n *html.Node) (*html.Node, bool) { return c.PrevSibling, false } - if c.Type == html.ElementNode && isHidden(c) { + if c.Type == html.ElementNode && cv.isHidden(c) { continue } @@ -873,7 +877,11 @@ func isPreheaderMark(r rune) bool { // isHidden reports whether an element is kept out of the rendered message. // Preheader text meant only for the inbox preview is the usual case. -func isHidden(n *html.Node) bool { +func (cv *conversion) isHidden(n *html.Node) bool { + if cv.opts.hiddenContent { + return false + } + for _, a := range n.Attr { switch a.Key { case "hidden": @@ -892,7 +900,11 @@ func isHidden(n *html.Node) bool { return false } +// font-size:0 is left out: it is inherited and descendants reset it, as in the +// inline-block gap fix that wraps whole layouts func hiddenByStyle(style string) bool { + var zeroHeight, overflowHidden bool + for declaration := range strings.SplitSeq(style, ";") { property, value, ok := strings.Cut(declaration, ":") if !ok { @@ -911,14 +923,18 @@ func hiddenByStyle(style string) bool { if value == "hidden" || value == "collapse" { return true } - case "opacity", "font-size", "max-height": + case "opacity": if isZeroValue(value) { return true } + case "max-height": + zeroHeight = isZeroValue(value) + case "overflow", "overflow-y": + overflowHidden = value == "hidden" } } - return false + return zeroHeight && overflowHidden } // isZeroValue reports whether a css number or length is zero, with or without a unit,