Skip to content
Open
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
72 changes: 72 additions & 0 deletions core/block/import/markdown/anymark/html_newlines_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package anymark

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anyproto/anytype-heart/pkg/lib/pb/model"
)

// collectText concatenates the text of all top-level paragraph text blocks.
func collectText(blocks []*model.Block) []string {
var texts []string
for _, b := range blocks {
if t := b.GetText(); t != nil {
texts = append(texts, t.Text)
}
}
return texts
}

func TestHTMLInlineNewlinesCollapse(t *testing.T) {
t.Run("strong content newlines collapse", func(t *testing.T) {
src := `<html><body><strong>000
111
222
</strong></body></html>`

blocks, _, err := HTMLToBlocks([]byte(src), "http://test.com/test")
require.NoError(t, err)

joined := strings.Join(collectText(blocks), "\n")
assert.Equal(t, "000 111 222", joined,
"source newlines inside <strong> must collapse to spaces, got:\n%q", joined)
assert.NotContains(t, joined, "\n")
})

t.Run("anchor content newlines collapse", func(t *testing.T) {
src := `<html><body><a href="https://anytype.io/">abc
def
hij</a></body></html>`

blocks, _, err := HTMLToBlocks([]byte(src), "http://test.com/test")
require.NoError(t, err)

joined := strings.Join(collectText(blocks), "\n")
assert.Equal(t, "abc def hij", joined,
"source newlines inside <a> must collapse to spaces, got:\n%q", joined)
assert.NotContains(t, joined, "\n")
})

t.Run("pre block keeps newlines", func(t *testing.T) {
src := `<html><body>
<pre>line one
line two</pre>
</body></html>`

blocks, _, err := HTMLToBlocks([]byte(src), "http://test.com/test")
require.NoError(t, err)

var codeText string
for _, b := range blocks {
if t := b.GetText(); t != nil && t.Style == model.BlockContentText_Code {
codeText = t.Text
}
}
assert.Contains(t, codeText, "\n", "whitespace in <pre> must be preserved, got:\n%q", codeText)
assert.Contains(t, codeText, "line one", "got:\n%q", codeText)
})
}
70 changes: 70 additions & 0 deletions core/block/import/markdown/anymark/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,75 @@ func escapeAll(n *html.Node) {
}
}

// collapseTextWhitespace replaces interior runs of whitespace inside a text
// node with a single space, mirroring HTML's inline whitespace collapsing.
// Only interior runs (whitespace with content on both sides of it) are
// collapsed: leading and trailing whitespace is preserved so newlines that
// separate sibling inline elements still break them into distinct items (e.g.
// pasted "1. One\n2. Two" list-like spans). A whitespace-only node is left as
// a single space so adjacent inline elements don't get glued together.
func collapseTextWhitespace(s string) string {
isSpace := func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v'
}
if !strings.ContainsFunc(s, isSpace) {
return s
}
buf := []rune(s)
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(buf) {
j := i
for j < len(buf) && !isSpace(buf[j]) {
j++
}
b.WriteString(string(buf[i:j]))
i = j
j = i
for j < len(buf) && isSpace(buf[j]) {
j++
}
if j > i {
if i == 0 || j == len(buf) {
b.WriteString(string(buf[i:j]))
} else {
b.WriteByte(' ')
}
i = j
}
}
out := b.String()
if strings.Trim(out, " \t\n\r\f\v") == "" {
return " "
}
return out
}

// collapseInlineTextWhitespace walks the DOM and collapses whitespace in text
// nodes, except inside elements where whitespace is significant (pre, textarea,
// script, style). This fixes imports where source newlines inside inline
// elements were turning into unwanted line breaks in the content.
func collapseInlineTextWhitespace(n *html.Node) {
switch n.Type {
case html.TextNode:
n.Data = collapseTextWhitespace(n.Data)
return
case html.ElementNode:
switch n.Data {
case "pre", "textarea", "script", "style":
return
}
case html.DocumentNode:
case html.DoctypeNode:
case html.CommentNode:
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collapseInlineTextWhitespace(c)
}
}

// escapeRecursively mutates every text-node under sel (including sel itself)
func escapeRecursively(sel *goquery.Selection) {
// operate on the direct text children of this element
Expand Down Expand Up @@ -109,6 +178,7 @@ func HTMLToBlocks(source []byte, url string) (blocks []*model.Block, rootBlockID
})
converter.Before(func(selec *goquery.Selection) {
for _, n := range selec.Nodes { // the hook can hand you several roots
collapseInlineTextWhitespace(n)
escapeAll(n)
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
{
"desc": "Link",
"html": "<meta charset='utf-8'>\n<div class=\"il\" id=\"paywall-fewerClicksHeading\" style=\"box-sizing: inherit; visibility: visible; color: rgba(0, 0, 0, 0.8); font-family: medium-content-sans-serif-font, -apple-system, system-ui, &quot;Segoe UI&quot;, Roboto, Oxygen, Ubuntu,\n Cantarell, &quot;Open Sans&quot;, &quot;Helvetica Neue&quot;, sans-serif; font-size: medium; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center;\n text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;\">\n <h2 class=\"jh ji jj jk jl jm jn gi jo gj jp gk da\" style=\"box-sizing: inherit; margin: 0px; color: rgba(0, 0, 0, 0.84); font-family: medium-marketing-display-font, Georgia, Cambria, &quot;Times New Roman&quot;, Times, serif; font-weight: 500;\n line-height: 32px; font-size: 28px;\">\n <div class=\"r jq jr jg\" style=\"box-sizing: inherit; display: block; text-align: left;\">Vladimir, get unlimited access.</div>\n </h2>\n</div>\n<h4 class=\"bp fu js jt ju jv jw jx jy jz ka kb da\" style=\"box-sizing: inherit; margin: 0px; font-family: medium-content-sans-serif-font, &quot;Lucida Grande&quot;, &quot;Lucida Sans Unicode&quot;, &quot;Lucida Sans&quot;, Geneva, Arial,\n sans-serif; color: rgba(0, 0, 0, 0.84); font-weight: 300; font-size: 18px; line-height: 24px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px;\n text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;\">\n <div class=\"il\" id=\"paywall-second-header-fewer-clicks\" style=\"box-sizing: inherit; visibility: visible;\">\n <div class=\"kc kd r ke\" style=\"box-sizing: inherit; display: block; margin-bottom: 28px; margin-top: 8px;\">\n <div class=\"r jq jg\" style=\"box-sizing: inherit; display: block; text-align: left;\">You've read all of your free stories this month. Become a member to keep reading.</div>\n </div>\n </div>\n <div class=\"n kf kg\" style=\"box-sizing: inherit; display: flex; justify-content: flex-start;\">\n <div style=\"box-sizing: inherit;\">\n <div class=\"ce io\" style=\"box-sizing: inherit; display: inline-block; text-align: center;\">\n <div class=\"il\" id=\"paywall-upsell-button-upgrade\" style=\"box-sizing: inherit; visibility: visible;\"><a href=\"https://medium.com/payment?source=upgrade_membership---post_limit--c8c01551517b----------------------\" class=\"kh ki kj kk\n kl km kn bh di ko kp kq dm bp b bq br bs bt kr dn do dp ce dq bk\" rel=\"noopener\" style=\"box-sizing: border-box; color: rgb(255, 255, 255); text-decoration: none; -webkit-tap-highlight-color: transparent; font-weight: 400; font-family:\n medium-content-sans-serif-font, &quot;Lucida Grande&quot;, &quot;Lucida Sans Unicode&quot;, &quot;Lucida Sans&quot;, Geneva, Arial, sans-serif; font-style: normal; line-height: 20px; font-size: 15.8px; letter-spacing: 0px; display:\n inline-block; border-radius: 4px; border-width: 1px; border-style: solid; padding: 8px 16px; fill: rgb(255, 255, 255); background: rgb(3, 168, 124); border-color: rgb(3, 168, 124); width: 104px;\">Upgrade</a></div>\n </div>\n </div>\n </div>\n <div class=\"il\" id=\"paywall-subtitle-copy-fewer-clicks\" style=\"box-sizing: inherit; visibility: visible;\">\n <div class=\"ks ho kc kd r ke\" style=\"box-sizing: inherit; display: block; padding-right: 6px; margin-bottom: 28px; margin-top: 8px; padding-left: 6px;\">\n <div class=\"kc kt r jq\" style=\"box-sizing: inherit; display: block; text-align: left; margin-bottom: 28px; margin-top: 28px;\">Here's what you get when you upgrade to membership:<div class=\"kc kt r\" style=\"box-sizing: inherit; display:\n block; margin-bottom: 28px; margin-top: 28px;\"><b style=\"box-sizing: inherit;\">Unlimited access.<span> </span></b>Explore<span> </span>Medium's library filled with everything you're curious about.</div>\n <div class=\"kc r\" style=\"box-sizing: inherit; display: block; margin-bottom: 28px;\"><b style=\"box-sizing: inherit;\">No ads.</b><span> </span>There are zero ads on<span> </span>Medium<span> </span>and we don't sell your data.</div>\n <div class=\"kc r\" style=\"box-sizing: inherit; display: block; margin-bottom: 28px;\"><b style=\"box-sizing: inherit;\">Reward quality writing.</b><span> </span>When you spend time reading a story, a portion of your membership fee will go\n directly to its author.</div>\n <div class=\"kc r\" style=\"box-sizing: inherit; display: block; margin-bottom: 28px;\">\"I love<span> </span>Medium's membership — it gives me access to the stories I love by the writers I love, and it allows me to help support those writers\n fina</div>\n </div>\n </div>\n </div>\n</h4>",
"blocks": [{"id":"1","Content":{"text":{"text":"Vladimir, get unlimited access.","style":2,"marks":{}}}},{"id":"2","Content":{"text":{"text":"You've read all of your free stories this month. Become a member to keep reading.","style":3,"marks":{}}}},{"id":"3","Content":{"text":{"text":"Upgrade","marks":{"marks":[{"range":{"to":7},"type":5,"param":"https://medium.com/payment?source=upgrade_membership---post_limit--c8c01551517b----------------------"}]}}}},{"id":"4","Content":{"text":{"text":"Here's what you get when you upgrade to membership:","marks":{}}}},{"id":"5","Content":{"text":{"text":"Unlimited access. Explore Medium's library filled with everything you're curious about.","marks":{"marks":[{"range":{"to":17},"type":3}]}}}},{"id":"6","Content":{"text":{"text":"No ads. There are zero ads on Medium and we don't sell your data.","marks":{"marks":[{"range":{"to":7},"type":3}]}}}},{"id":"7","Content":{"text":{"text":"Reward quality writing. When you spend time reading a story, a portion of your membership fee will go\ndirectly to its author.","marks":{"marks":[{"range":{"to":23},"type":3}]}}}},{"id":"8","Content":{"text":{"text":"\"I love Medium's membership — it gives me access to the stories I love by the writers I love, and it allows me to help support those writers\nfina","marks":{}}}}]
"blocks": [{"id":"1","Content":{"text":{"text":"Vladimir, get unlimited access.","style":2,"marks":{}}}},{"id":"2","Content":{"text":{"text":"You've read all of your free stories this month. Become a member to keep reading.","style":3,"marks":{}}}},{"id":"3","Content":{"text":{"text":"Upgrade","marks":{"marks":[{"range":{"to":7},"type":5,"param":"https://medium.com/payment?source=upgrade_membership---post_limit--c8c01551517b----------------------"}]}}}},{"id":"4","Content":{"text":{"text":"Here's what you get when you upgrade to membership:","marks":{}}}},{"id":"5","Content":{"text":{"text":"Unlimited access. Explore Medium's library filled with everything you're curious about.","marks":{"marks":[{"range":{"to":17},"type":3}]}}}},{"id":"6","Content":{"text":{"text":"No ads. There are zero ads on Medium and we don't sell your data.","marks":{"marks":[{"range":{"to":7},"type":3}]}}}},{"id":"7","Content":{"text":{"text":"Reward quality writing. When you spend time reading a story, a portion of your membership fee will go directly to its author.","marks":{"marks":[{"range":{"to":23},"type":3}]}}}},{"id":"8","Content":{"text":{"text":"\"I love Medium's membership — it gives me access to the stories I love by the writers I love, and it allows me to help support those writers fina","marks":{}}}}]
},
{
"desc": "Nested List",
Expand Down
Loading