1
0

images.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. package clean
  2. import (
  3. "encoding/base64"
  4. "net/url"
  5. "strconv"
  6. "strings"
  7. "golang.org/x/net/html"
  8. "golang.org/x/net/html/atom"
  9. )
  10. // targetWidth is the image width worth downloading: the reMarkable 2's
  11. // screen width. Larger candidates only waste bandwidth.
  12. const targetWidth = 1404
  13. // lazySrcAttrs hold the real image URL on pages that lazy-load images.
  14. var lazySrcAttrs = []string{"data-src", "data-original", "data-lazy-src", "data-url", "data-hi-res-src"}
  15. var lazySrcsetAttrs = []string{"data-srcset", "data-lazy-srcset"}
  16. // prepareImages fixes images before extraction, because go-trafilatura only
  17. // looks at src:
  18. //
  19. // - lazy-loading attributes and the best srcset or <picture> candidate
  20. // are moved into src, and src is made absolute;
  21. // - tracking pixels (width or height of 2 or less) are removed;
  22. // - links that wrap nothing but an image, such as "click to zoom"
  23. // lightbox links, are unwrapped: go-trafilatura drops an image inside
  24. // such a link when it sits in a <div> or <p>, as on Ars Technica and
  25. // many WordPress sites, and the link is useless on an e-reader;
  26. // - each <figcaption> becomes an italic paragraph, which go-trafilatura
  27. // keeps (it drops captions); Content turns it back into a caption;
  28. // - every src is replaced by a stand-in URL (see protectSrc), because
  29. // go-trafilatura drops images whose URL doesn't end in an image file
  30. // extension; Content restores the real URL.
  31. func prepareImages(doc *html.Node, base *url.URL) {
  32. var imgs, captions, pixels, imageLinks []*html.Node
  33. walk(doc, func(n *html.Node) {
  34. switch n.DataAtom {
  35. case atom.A:
  36. if contains(n, atom.Img) && strings.TrimSpace(text(n)) == "" {
  37. imageLinks = append(imageLinks, n)
  38. }
  39. case atom.Img:
  40. if isTrackingPixel(n) {
  41. pixels = append(pixels, n)
  42. } else {
  43. imgs = append(imgs, n)
  44. }
  45. case atom.Figcaption:
  46. captions = append(captions, n)
  47. }
  48. })
  49. for _, n := range pixels {
  50. n.Parent.RemoveChild(n)
  51. }
  52. for _, a := range imageLinks {
  53. unwrap(a)
  54. }
  55. for _, n := range imgs {
  56. src := bestSource(n)
  57. if src == "" {
  58. continue
  59. }
  60. if !strings.HasPrefix(src, "data:") {
  61. u, err := base.Parse(src)
  62. if err != nil {
  63. continue
  64. }
  65. src = u.String()
  66. }
  67. setAttr(n, "src", protectSrc(src))
  68. }
  69. for _, c := range captions {
  70. // <figcaption>x</figcaption> → <p><i>x</i></p>
  71. i := &html.Node{Type: html.ElementNode, Data: "i", DataAtom: atom.I}
  72. if hasBlock(c) {
  73. // A paragraph can't hold blocks, so captions laid out with
  74. // <div>s (as on Ars Technica) are reduced to their text.
  75. caption := spacedText(c)
  76. for child := c.FirstChild; child != nil; child = c.FirstChild {
  77. c.RemoveChild(child)
  78. }
  79. i.AppendChild(&html.Node{Type: html.TextNode, Data: caption})
  80. } else {
  81. for child := c.FirstChild; child != nil; child = c.FirstChild {
  82. c.RemoveChild(child)
  83. i.AppendChild(child)
  84. }
  85. }
  86. c.Data, c.DataAtom, c.Attr = "p", atom.P, nil
  87. c.AppendChild(i)
  88. }
  89. }
  90. // bestSource picks the URL to download for an <img>: the best candidate
  91. // from its srcset, its <picture> sources or lazy-loading attributes, else
  92. // its src. A placeholder data: URL in src loses to any real URL.
  93. func bestSource(img *html.Node) string {
  94. var srcsets []string
  95. if pic := img.Parent; pic != nil && pic.DataAtom == atom.Picture {
  96. for s := pic.FirstChild; s != nil; s = s.NextSibling {
  97. if s.DataAtom == atom.Source && usableType(attr(s, "type")) {
  98. srcsets = append(srcsets, attr(s, "srcset"), attr(s, "data-srcset"))
  99. }
  100. }
  101. }
  102. srcsets = append(srcsets, attr(img, "srcset"))
  103. for _, a := range lazySrcsetAttrs {
  104. srcsets = append(srcsets, attr(img, a))
  105. }
  106. var candidates []candidate
  107. for _, s := range srcsets {
  108. candidates = append(candidates, parseSrcset(s)...)
  109. }
  110. if best := pickCandidate(candidates); best != "" {
  111. return best
  112. }
  113. for _, a := range lazySrcAttrs {
  114. if v := strings.TrimSpace(attr(img, a)); v != "" {
  115. return v
  116. }
  117. }
  118. return strings.TrimSpace(attr(img, "src"))
  119. }
  120. // usableType reports whether a <source> type is one we can decode. AVIF and
  121. // JPEG XL aren't supported, so their sources are skipped in favour of the
  122. // fallback.
  123. func usableType(t string) bool {
  124. switch strings.ToLower(strings.TrimSpace(t)) {
  125. case "", "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/svg+xml":
  126. return true
  127. }
  128. return false
  129. }
  130. type candidate struct {
  131. url string
  132. width int // from a "800w" descriptor, or 0
  133. density float64 // from a "2x" descriptor, or 0
  134. }
  135. // parseSrcset splits a srcset attribute into candidates. URLs may contain
  136. // commas (as some image CDNs use), so candidates are split on the comma
  137. // that follows a descriptor or whitespace, as the HTML spec describes.
  138. func parseSrcset(s string) []candidate {
  139. var out []candidate
  140. for s = strings.TrimSpace(s); s != ""; s = strings.TrimLeft(s, " \t\n\r\f,") {
  141. end := strings.IndexAny(s, " \t\n\r\f")
  142. if end < 0 {
  143. end = len(s)
  144. }
  145. u := s[:end]
  146. s = s[end:]
  147. var desc string
  148. if strings.HasSuffix(u, ",") {
  149. u = strings.TrimRight(u, ",")
  150. } else {
  151. comma := strings.IndexByte(s, ',')
  152. if comma < 0 {
  153. comma = len(s)
  154. }
  155. desc = strings.TrimSpace(s[:comma])
  156. s = s[comma:]
  157. }
  158. if u == "" || strings.HasPrefix(u, "data:") {
  159. continue
  160. }
  161. c := candidate{url: u}
  162. switch {
  163. case strings.HasSuffix(desc, "w"):
  164. c.width, _ = strconv.Atoi(strings.TrimSuffix(desc, "w"))
  165. case strings.HasSuffix(desc, "x"):
  166. c.density, _ = strconv.ParseFloat(strings.TrimSuffix(desc, "x"), 64)
  167. }
  168. out = append(out, c)
  169. }
  170. return out
  171. }
  172. // pickCandidate chooses the smallest candidate at least targetWidth wide,
  173. // or the widest if none is; without width descriptors it takes the highest
  174. // density.
  175. func pickCandidate(cs []candidate) string {
  176. var best *candidate
  177. for i := range cs {
  178. c := &cs[i]
  179. if c.width == 0 {
  180. continue
  181. }
  182. switch {
  183. case best == nil:
  184. best = c
  185. case best.width < targetWidth:
  186. if c.width > best.width {
  187. best = c
  188. }
  189. case c.width >= targetWidth && c.width < best.width:
  190. best = c
  191. }
  192. }
  193. if best != nil {
  194. return best.url
  195. }
  196. for i := range cs {
  197. if best == nil || cs[i].density > best.density {
  198. best = &cs[i]
  199. }
  200. }
  201. if best != nil {
  202. return best.url
  203. }
  204. return ""
  205. }
  206. // isTrackingPixel reports whether an <img> declares itself 2px or smaller.
  207. func isTrackingPixel(n *html.Node) bool {
  208. for _, key := range []string{"width", "height"} {
  209. if v, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(attr(n, key)), "px")); err == nil && v <= 2 {
  210. return true
  211. }
  212. }
  213. return false
  214. }
  215. // buildFigures wraps each standalone image (not inside a paragraph or
  216. // other text) in a <figure>, taking a following paragraph that is entirely
  217. // italic as its caption; prepareImages turned captions into exactly that.
  218. func buildFigures(body *html.Node) {
  219. var standalone []*html.Node
  220. walk(body, func(n *html.Node) {
  221. if n.DataAtom == atom.Img && n.Parent != nil && blockParents[n.Parent.DataAtom] {
  222. standalone = append(standalone, n)
  223. }
  224. })
  225. for _, img := range standalone {
  226. fig := &html.Node{Type: html.ElementNode, Data: "figure", DataAtom: atom.Figure}
  227. img.Parent.InsertBefore(fig, img)
  228. next := nextElement(img)
  229. img.Parent.RemoveChild(img)
  230. fig.AppendChild(img)
  231. if next != nil && isCaption(next) {
  232. i := firstElement(next)
  233. next.Parent.RemoveChild(next)
  234. caption := &html.Node{Type: html.ElementNode, Data: "figcaption", DataAtom: atom.Figcaption}
  235. for c := i.FirstChild; c != nil; c = i.FirstChild {
  236. i.RemoveChild(c)
  237. caption.AppendChild(c)
  238. }
  239. fig.AppendChild(caption)
  240. }
  241. }
  242. }
  243. // isCaption reports whether p is a paragraph holding only one <i> or <em>.
  244. func isCaption(p *html.Node) bool {
  245. if p.DataAtom != atom.P {
  246. return false
  247. }
  248. i := firstElement(p)
  249. if i == nil || (i.DataAtom != atom.I && i.DataAtom != atom.Em) || nextElement(i) != nil {
  250. return false
  251. }
  252. for c := p.FirstChild; c != nil; c = c.NextSibling {
  253. if c.Type == html.TextNode && strings.TrimSpace(c.Data) != "" {
  254. return false
  255. }
  256. }
  257. return strings.TrimSpace(text(i)) != ""
  258. }
  259. // nextElement returns the next sibling element of n, skipping whitespace.
  260. func nextElement(n *html.Node) *html.Node {
  261. for s := n.NextSibling; s != nil; s = s.NextSibling {
  262. if s.Type == html.ElementNode {
  263. return s
  264. }
  265. if s.Type == html.TextNode && strings.TrimSpace(s.Data) != "" {
  266. return nil
  267. }
  268. }
  269. return nil
  270. }
  271. func firstElement(n *html.Node) *html.Node {
  272. for c := n.FirstChild; c != nil; c = c.NextSibling {
  273. if c.Type == html.ElementNode {
  274. return c
  275. }
  276. }
  277. return nil
  278. }
  279. // hasBlock reports whether n contains block-level elements.
  280. func hasBlock(n *html.Node) bool {
  281. found := false
  282. walk(n, func(d *html.Node) {
  283. switch d.DataAtom {
  284. case atom.Div, atom.P, atom.Ul, atom.Ol, atom.Table, atom.Blockquote, atom.Section, atom.Figure:
  285. found = found || d != n
  286. }
  287. })
  288. return found
  289. }
  290. // spacedText returns the text of n with a space at every element boundary
  291. // and whitespace collapsed, so "dialog.<span>Credit: KDE</span>" reads
  292. // "dialog. Credit: KDE".
  293. func spacedText(n *html.Node) string {
  294. var b strings.Builder
  295. var rec func(*html.Node)
  296. rec = func(n *html.Node) {
  297. switch n.Type {
  298. case html.TextNode:
  299. b.WriteString(n.Data)
  300. case html.ElementNode:
  301. b.WriteByte(' ')
  302. }
  303. for c := n.FirstChild; c != nil; c = c.NextSibling {
  304. rec(c)
  305. }
  306. if n.Type == html.ElementNode {
  307. b.WriteByte(' ')
  308. }
  309. }
  310. rec(n)
  311. return strings.Join(strings.Fields(b.String()), " ")
  312. }
  313. // srcStandIn prefixes the stand-in URLs that carry image sources through
  314. // go-trafilatura. The .invalid domain is reserved and never resolves, so a
  315. // stand-in that somehow escaped could not reach anyone's server.
  316. const srcStandIn = "https://rekopub.invalid/img/"
  317. // protectSrc wraps an image URL in a stand-in ending in ".png".
  318. // go-trafilatura keeps an image only when filepath.Ext of its whole URL is
  319. // an image type, so it drops URLs with a query string
  320. // ("photo.jpg?utm_source=…", as on Wikipedia), extensionless CDN URLs and
  321. // data: URLs. The stand-in passes that check; restoreSrc undoes it.
  322. func protectSrc(src string) string {
  323. return srcStandIn + base64.RawURLEncoding.EncodeToString([]byte(src)) + ".png"
  324. }
  325. // restoreSrc returns the original URL inside a stand-in made by
  326. // protectSrc, or src unchanged if it isn't one.
  327. func restoreSrc(src string) string {
  328. enc, ok := strings.CutPrefix(src, srcStandIn)
  329. if !ok {
  330. return src
  331. }
  332. raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSuffix(enc, ".png"))
  333. if err != nil {
  334. return ""
  335. }
  336. return string(raw)
  337. }
  338. // restoreImageSources replaces every stand-in src under n with its
  339. // original URL. It matches on the tag name: go-trafilatura creates its
  340. // output elements without setting DataAtom.
  341. func restoreImageSources(n *html.Node) {
  342. walk(n, func(n *html.Node) {
  343. if n.Data == "img" {
  344. for i, a := range n.Attr {
  345. if a.Key == "src" {
  346. n.Attr[i].Val = restoreSrc(a.Val)
  347. }
  348. }
  349. }
  350. })
  351. }
  352. // unwrap replaces n with its children.
  353. func unwrap(n *html.Node) {
  354. if n.Parent == nil {
  355. return
  356. }
  357. for c := n.FirstChild; c != nil; c = n.FirstChild {
  358. n.RemoveChild(c)
  359. n.Parent.InsertBefore(c, n)
  360. }
  361. n.Parent.RemoveChild(n)
  362. }
  363. func setAttr(n *html.Node, key, val string) {
  364. for i := range n.Attr {
  365. if n.Attr[i].Key == key {
  366. n.Attr[i].Val = val
  367. return
  368. }
  369. }
  370. n.Attr = append(n.Attr, html.Attribute{Key: key, Val: val})
  371. }