Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `<a href="https://example.com">Docs</a>` becomes |
Expand Down
7 changes: 7 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type options struct {
links LinkStyle
plainHeadings bool
markdown bool
hiddenContent bool
}

func newOptions(opts []Option) options {
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ func TestOptionsDefaultToTheDefaultLineLength(t *testing.T) {
}
}

func TestOptionsHiddenContent(t *testing.T) {
body := `<div style="display:none"><p>only content</p></div>`

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(`<p>text<a href="https://a.example"></a></p>`, textplain.WithLinks(textplain.LinksFootnotes))
require.NoError(t, err)
Expand Down
19 changes: 12 additions & 7 deletions textplain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -951,13 +951,8 @@ func TestHiddenContent(t *testing.T) {
expect: "shown",
},
testCase{
name: "zero font size with a unit",
body: `<p>shown</p><p style="font-size:0px">hidden</p>`,
expect: "shown",
},
testCase{
name: "zero max height",
body: `<p>shown</p><p style="max-height:0">hidden</p>`,
name: "zero max height with overflow hidden",
body: `<p>shown</p><p style="max-height:0px;overflow:hidden">hidden</p>`,
expect: "shown",
},
testCase{
Expand Down Expand Up @@ -1001,6 +996,16 @@ func TestHiddenContent(t *testing.T) {
body: `<p>shown</p><p style="font-size:0.9em">still visible</p>`,
expect: "shown\n\nstill visible",
},
testCase{
name: "zero font size container with sized children stays visible",
body: `<div style="font-size:0"><table style="display:inline-block"><tr><td style="font-size:16px">still visible</td></tr></table></div>`,
expect: "still visible",
},
testCase{
name: "zero max height without overflow hidden stays visible",
body: `<div style="max-height:0"><p>still visible</p></div>`,
expect: "still visible",
},
testCase{
name: "aria-hidden false stays visible",
body: `<p>shown</p><p aria-hidden="false">still visible</p>`,
Expand Down
32 changes: 24 additions & 8 deletions tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
}
Expand All @@ -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)
Expand Down Expand Up @@ -190,7 +194,7 @@ func (cv *conversion) doConvert(o *output, n *html.Node) {
continue
}

if isHidden(c) {
if cv.isHidden(c) {
continue
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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":
Expand All @@ -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 {
Expand All @@ -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,
Expand Down
Loading