From b211d0b1cdc66991b64241d22c0cf47113b6f41d Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Sat, 26 Sep 2026 12:57:56 -0400 Subject: [PATCH 1/2] fix: stop hiding containers styled font-size:0 or bare max-height:0, and fall back to hidden content rather than return empty output --- textplain_test.go | 24 +++++++++++++++++------- tree.go | 46 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/textplain_test.go b/textplain_test.go index 4e4ed73..a46585d 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,21 @@ 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: "everything hidden falls back to showing it", + body: `

only content

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

shown

still visible

`, diff --git a/tree.go b/tree.go index 45b1533..d55c6a8 100644 --- a/tree.go +++ b/tree.go @@ -58,8 +58,9 @@ func withoutMarkers(s string) string { // conversion holds the state of a single Convert call. type conversion struct { - opts options - links []string + opts options + links []string + showHidden bool } // Convert renders the body of document as plain text, wrapping at @@ -77,13 +78,24 @@ 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)} + + text := cv.convert(body) + if text == "" { + // hiding is guessed from inline styles, and a wrong guess must not empty the message + cv = &conversion{opts: cv.opts, showHidden: true} + text = cv.convert(body) + } + + return text, nil +} + +func (cv *conversion) convert(body *html.Node) string { var o output cv.doConvert(&o, body) @@ -109,7 +121,7 @@ func ConvertReader(r io.Reader, opts ...Option) (string, error) { wrapped = restorePre(wrapped, preformatted) - return applyQuotes(strings.ReplaceAll(wrapped, indentMark, " ")) + cv.footnotes(), nil + return applyQuotes(strings.ReplaceAll(wrapped, indentMark, " ")) + cv.footnotes() } // footnotes lists the collected link targets under the body @@ -190,7 +202,7 @@ func (cv *conversion) doConvert(o *output, n *html.Node) { continue } - if isHidden(c) { + if cv.isHidden(c) { continue } @@ -730,7 +742,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 +801,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 +885,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.showHidden { + return false + } + for _, a := range n.Attr { switch a.Key { case "hidden": @@ -892,7 +908,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 +931,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, From 5d9435c2bd1f58648488f2aa4ac5683f81067207 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Sat, 26 Sep 2026 13:07:40 -0400 Subject: [PATCH 2/2] fix: replace the automatic empty-output fallback with a WithHiddenContent option for callers to retry with --- README.md | 3 +++ options.go | 7 +++++++ options_test.go | 12 ++++++++++++ textplain_test.go | 5 ----- tree.go | 24 ++++++++---------------- 5 files changed, 30 insertions(+), 21 deletions(-) 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 a46585d..ffdd087 100644 --- a/textplain_test.go +++ b/textplain_test.go @@ -1006,11 +1006,6 @@ func TestHiddenContent(t *testing.T) { body: `

still visible

`, expect: "still visible", }, - testCase{ - name: "everything hidden falls back to showing it", - body: `

only content

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

shown

still visible

`, diff --git a/tree.go b/tree.go index d55c6a8..4b69c17 100644 --- a/tree.go +++ b/tree.go @@ -58,14 +58,17 @@ func withoutMarkers(s string) string { // conversion holds the state of a single Convert call. type conversion struct { - opts options - links []string - showHidden bool + opts options + links []string } // 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...) } @@ -85,17 +88,6 @@ func ConvertReader(r io.Reader, opts ...Option) (string, error) { cv := &conversion{opts: newOptions(opts)} - text := cv.convert(body) - if text == "" { - // hiding is guessed from inline styles, and a wrong guess must not empty the message - cv = &conversion{opts: cv.opts, showHidden: true} - text = cv.convert(body) - } - - return text, nil -} - -func (cv *conversion) convert(body *html.Node) string { var o output cv.doConvert(&o, body) @@ -121,7 +113,7 @@ func (cv *conversion) convert(body *html.Node) string { wrapped = restorePre(wrapped, preformatted) - return applyQuotes(strings.ReplaceAll(wrapped, indentMark, " ")) + cv.footnotes() + return applyQuotes(strings.ReplaceAll(wrapped, indentMark, " ")) + cv.footnotes(), nil } // footnotes lists the collected link targets under the body @@ -886,7 +878,7 @@ 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 (cv *conversion) isHidden(n *html.Node) bool { - if cv.showHidden { + if cv.opts.hiddenContent { return false }