build_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. package build
  2. import (
  3. "bytes"
  4. "image"
  5. imgjpeg "image/jpeg"
  6. imgpng "image/png"
  7. "net/url"
  8. "os"
  9. "path/filepath"
  10. "regexp"
  11. "strings"
  12. "testing"
  13. "time"
  14. "golang.org/x/net/html"
  15. "git.monoexp.com/bajdev/rekopub/internal/assets"
  16. "git.monoexp.com/bajdev/rekopub/internal/clean"
  17. "git.monoexp.com/bajdev/rekopub/internal/epubtest"
  18. "git.monoexp.com/bajdev/rekopub/internal/extract"
  19. )
  20. func testArticle(t *testing.T, body string) *extract.Article {
  21. t.Helper()
  22. doc, err := html.Parse(strings.NewReader("<body>" + body + "</body>"))
  23. if err != nil {
  24. t.Fatal(err)
  25. }
  26. return &extract.Article{
  27. URL: "https://example.com/posts/1?a=1&b=2",
  28. Title: "Tom & Jerry <3",
  29. Author: "Ada Lovelace",
  30. SiteName: "Example News",
  31. Description: "A test article.",
  32. Language: "en",
  33. Published: time.Date(2025, 3, 14, 9, 30, 0, 0, time.UTC),
  34. Content: doc.FirstChild.LastChild, // html > body
  35. }
  36. }
  37. func buildBytes(t *testing.T, a *extract.Article) []byte {
  38. t.Helper()
  39. base, err := url.Parse(a.URL)
  40. if err != nil {
  41. t.Fatal(err)
  42. }
  43. body, err := clean.Content(a.Content, base)
  44. if err != nil {
  45. t.Fatal(err)
  46. }
  47. data, err := Build(a, body, nil, nil)
  48. if err != nil {
  49. t.Fatal(err)
  50. }
  51. return data
  52. }
  53. func TestBuild(t *testing.T) {
  54. a := testArticle(t, `<h1>Section</h1><p>First &amp; <em>second</em>.<br>Line two.</p><hr><ul><li>One</li></ul><script>evil()</script>`)
  55. data := buildBytes(t, a)
  56. book := epubtest.Open(t, data)
  57. book.AssertWellFormed(t)
  58. opf := book.File(t, ".opf")
  59. for _, want := range []string{
  60. "<dc:title>Tom &amp; Jerry &lt;3</dc:title>",
  61. "<dc:language>en</dc:language>",
  62. "Ada Lovelace",
  63. "A test article.",
  64. "urn:uuid:",
  65. } {
  66. if !strings.Contains(opf, want) {
  67. t.Errorf("OPF is missing %q:\n%s", want, opf)
  68. }
  69. }
  70. chapter := book.File(t, "chapter.xhtml")
  71. for _, want := range []string{
  72. `<div lang="en" xml:lang="en">`,
  73. `<link rel="stylesheet" type="text/css" href="../css/style.css"`,
  74. "<h1>Tom &amp; Jerry &lt;3</h1>",
  75. `<p class="byline">Ada Lovelace · Example News · 14 March 2025</p>`,
  76. `<a href="https://example.com/posts/1?a=1&amp;b=2">`,
  77. `<h2 id="sec-1">Section</h2>`, // shifted down from <h1>, anchored for the contents
  78. "First &amp; <em>second</em>.<br/>Line two.",
  79. "<hr/>",
  80. "<li>One</li>",
  81. } {
  82. if !strings.Contains(chapter, want) {
  83. t.Errorf("chapter is missing %q:\n%s", want, chapter)
  84. }
  85. }
  86. if strings.Contains(chapter, "evil()") {
  87. t.Errorf("chapter contains a script:\n%s", chapter)
  88. }
  89. if css := book.File(t, "style.css"); !strings.Contains(css, "border-left: 3px solid #000") {
  90. t.Errorf("stylesheet missing or unexpected:\n%s", css)
  91. }
  92. path := filepath.Join(t.TempDir(), "book.epub")
  93. if err := os.WriteFile(path, data, 0o644); err != nil {
  94. t.Fatal(err)
  95. }
  96. epubtest.EPUBCheck(t, path)
  97. }
  98. func TestBuildIdentifierIsStable(t *testing.T) {
  99. id := regexp.MustCompile(`urn:uuid:[0-9a-f-]{36}`)
  100. a := testArticle(t, "<p>text</p>")
  101. first := id.FindString(epubtest.Open(t, buildBytes(t, a)).File(t, ".opf"))
  102. second := id.FindString(epubtest.Open(t, buildBytes(t, a)).File(t, ".opf"))
  103. if first == "" || first != second {
  104. t.Errorf("identifiers %q and %q, want the same non-empty identifier", first, second)
  105. }
  106. a.URL = "https://example.com/other"
  107. if other := id.FindString(epubtest.Open(t, buildBytes(t, a)).File(t, ".opf")); other == first {
  108. t.Errorf("different URLs gave the same identifier %q", other)
  109. }
  110. }
  111. func TestByline(t *testing.T) {
  112. date := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
  113. tests := []struct {
  114. author, site string
  115. date time.Time
  116. want string
  117. }{
  118. {"A", "S", date, "A · S · 2 January 2025"},
  119. {"", "S", date, "S · 2 January 2025"},
  120. {"A", "", time.Time{}, "A"},
  121. {"", "", time.Time{}, ""},
  122. }
  123. for _, tt := range tests {
  124. got := byline(&extract.Article{Author: tt.author, SiteName: tt.site, Published: tt.date})
  125. if got != tt.want {
  126. t.Errorf("byline(%q, %q, %v) = %q, want %q", tt.author, tt.site, tt.date, got, tt.want)
  127. }
  128. }
  129. }
  130. func TestChapterWithoutByline(t *testing.T) {
  131. a := testArticle(t, "<p>text</p>")
  132. a.Author, a.SiteName, a.Published = "", "", time.Time{}
  133. chapter := epubtest.Open(t, buildBytes(t, a)).File(t, "chapter.xhtml")
  134. if strings.Contains(chapter, "byline") {
  135. t.Errorf("chapter has a byline paragraph:\n%s", chapter)
  136. }
  137. }
  138. func TestBuildWithImages(t *testing.T) {
  139. var png bytes.Buffer
  140. img := image.NewGray(image.Rect(0, 0, 40, 30))
  141. if err := imgpng.Encode(&png, img); err != nil {
  142. t.Fatal(err)
  143. }
  144. a := testArticle(t, "")
  145. body, err := clean.Content(a.Content, mustURL(t, a.URL))
  146. if err != nil {
  147. t.Fatal(err)
  148. }
  149. // As left by the assets package: src is the image's filename. The second
  150. // image wasn't processed and must not end up in the book.
  151. frag, err := html.ParseFragment(strings.NewReader(`<figure><img src="abc123.png" alt="A chart"/></figure><p>x <img src="https://remote.example/a.png" alt=""/></p>`), body)
  152. if err != nil {
  153. t.Fatal(err)
  154. }
  155. for _, n := range frag {
  156. body.AppendChild(n)
  157. }
  158. data, err := Build(a, body, []assets.Image{{Filename: "abc123.png", MediaType: "image/png", Data: png.Bytes()}}, nil)
  159. if err != nil {
  160. t.Fatal(err)
  161. }
  162. book := epubtest.Open(t, data)
  163. book.AssertWellFormed(t)
  164. if got := book.File(t, "images/abc123.png"); got != png.String() {
  165. t.Errorf("stored image differs from the original")
  166. }
  167. chapter := book.File(t, "chapter.xhtml")
  168. if !strings.Contains(chapter, `<img src="../images/abc123.png" alt="A chart"/>`) {
  169. t.Errorf("chapter doesn't reference the stored image:\n%s", chapter)
  170. }
  171. if strings.Contains(chapter, "remote.example") {
  172. t.Errorf("chapter references a remote image:\n%s", chapter)
  173. }
  174. if opf := book.File(t, ".opf"); !strings.Contains(opf, `href="images/abc123.png"`) || !strings.Contains(opf, `media-type="image/png"`) {
  175. t.Errorf("OPF manifest is missing the image:\n%s", opf)
  176. }
  177. path := filepath.Join(t.TempDir(), "book.epub")
  178. if err := os.WriteFile(path, data, 0o644); err != nil {
  179. t.Fatal(err)
  180. }
  181. epubtest.EPUBCheck(t, path)
  182. }
  183. func mustURL(t *testing.T, raw string) *url.URL {
  184. t.Helper()
  185. u, err := url.Parse(raw)
  186. if err != nil {
  187. t.Fatal(err)
  188. }
  189. return u
  190. }
  191. func TestBuildMetadataCoverAndContents(t *testing.T) {
  192. a := testArticle(t, `<p>Intro.</p><h2>First <em>part</em></h2><p>a</p><h3>Detail</h3><p>b</p><h2>Second part</h2><p>c</p><h4>Too deep</h4>`)
  193. var jpg bytes.Buffer
  194. if err := imgjpeg.Encode(&jpg, image.NewGray(image.Rect(0, 0, 30, 40)), nil); err != nil {
  195. t.Fatal(err)
  196. }
  197. body, err := clean.Content(a.Content, mustURL(t, a.URL))
  198. if err != nil {
  199. t.Fatal(err)
  200. }
  201. data, err := Build(a, body, nil, jpg.Bytes())
  202. if err != nil {
  203. t.Fatal(err)
  204. }
  205. book := epubtest.Open(t, data)
  206. book.AssertWellFormed(t)
  207. opf := book.File(t, ".opf")
  208. for _, want := range []string{
  209. "<dc:publisher>Example News</dc:publisher>",
  210. "<dc:source>https://example.com/posts/1?a=1&amp;b=2</dc:source>",
  211. "<dc:date>2025-03-14</dc:date>",
  212. `href="images/cover.jpg" media-type="image/jpeg" properties="cover-image"`,
  213. `<itemref idref="cover.xhtml">`,
  214. } {
  215. if !strings.Contains(opf, want) {
  216. t.Errorf("OPF is missing %q:\n%s", want, opf)
  217. }
  218. }
  219. if got := book.File(t, "images/cover.jpg"); got != jpg.String() {
  220. t.Errorf("stored cover differs from the original")
  221. }
  222. chapter := book.File(t, "chapter.xhtml")
  223. for _, want := range []string{`<h2 id="sec-1">First <em>part</em></h2>`, `<h3 id="sec-2">Detail</h3>`, `<h2 id="sec-3">Second part</h2>`, "<h4>Too deep</h4>"} {
  224. if !strings.Contains(chapter, want) {
  225. t.Errorf("chapter is missing %q", want)
  226. }
  227. }
  228. nav := book.File(t, "nav.xhtml")
  229. navWant := `<li><a href="xhtml/chapter.xhtml">Tom &amp; Jerry &lt;3</a>
  230. <ol>
  231. <li><a href="xhtml/chapter.xhtml#sec-1">First part</a>
  232. <ol>
  233. <li><a href="xhtml/chapter.xhtml#sec-2">Detail</a></li>
  234. </ol>
  235. </li>
  236. <li><a href="xhtml/chapter.xhtml#sec-3">Second part</a></li>
  237. </ol>
  238. </li>`
  239. squash := func(s string) string { return strings.Join(strings.Fields(s), " ") }
  240. if !strings.Contains(squash(nav), squash(navWant)) {
  241. t.Errorf("nav document:\n%s\nwant it to contain:\n%s", nav, navWant)
  242. }
  243. ncx := book.File(t, "toc.ncx")
  244. id := regexp.MustCompile(`urn:uuid:[0-9a-f-]{36}`).FindString(opf)
  245. for _, want := range []string{
  246. `<meta name="dtb:uid" content="` + id + `"/>`,
  247. `<meta name="dtb:depth" content="3"/>`,
  248. `<navPoint id="navpoint-1" playOrder="1">`,
  249. `<navPoint id="navpoint-4" playOrder="4">`,
  250. `<content src="xhtml/chapter.xhtml#sec-2"/>`,
  251. `<navLabel><text>Second part</text></navLabel>`,
  252. } {
  253. if !strings.Contains(ncx, want) {
  254. t.Errorf("NCX is missing %q:\n%s", want, ncx)
  255. }
  256. }
  257. path := filepath.Join(t.TempDir(), "book.epub")
  258. if err := os.WriteFile(path, data, 0o644); err != nil {
  259. t.Fatal(err)
  260. }
  261. epubtest.EPUBCheck(t, path)
  262. }
  263. func TestBuildWithoutHeadingsOrDate(t *testing.T) {
  264. a := testArticle(t, "<p>Just text.</p>")
  265. a.SiteName, a.Published = "", time.Time{}
  266. book := epubtest.Open(t, buildBytes(t, a))
  267. opf := book.File(t, ".opf")
  268. if strings.Contains(opf, "dc:publisher") || strings.Contains(opf, "dc:date") {
  269. t.Errorf("OPF has empty publisher or date:\n%s", opf)
  270. }
  271. if !strings.Contains(book.File(t, "nav.xhtml"), `<li><a href="xhtml/chapter.xhtml">Tom &amp; Jerry &lt;3</a></li>`) {
  272. t.Errorf("nav should list only the article:\n%s", book.File(t, "nav.xhtml"))
  273. }
  274. if !strings.Contains(book.File(t, "toc.ncx"), `<meta name="dtb:depth" content="1"/>`) {
  275. t.Errorf("NCX depth should be 1")
  276. }
  277. }