epub.ex (5365B)
1 defmodule ExDoc.Formatter.EPUB do 2 @moduledoc false 3 4 @mimetype "application/epub+zip" 5 alias __MODULE__.{Assets, Templates} 6 alias ExDoc.Formatter.HTML 7 8 @doc """ 9 Generate EPUB documentation for the given modules. 10 """ 11 @spec run(list, ExDoc.Config.t()) :: String.t() 12 def run(project_nodes, config) when is_map(config) do 13 config = normalize_config(config) 14 File.rm_rf!(config.output) 15 File.mkdir_p!(Path.join(config.output, "OEBPS")) 16 17 project_nodes = HTML.render_all(project_nodes, ".xhtml", config, highlight_tag: "samp") 18 19 nodes_map = %{ 20 modules: HTML.filter_list(:module, project_nodes), 21 tasks: HTML.filter_list(:task, project_nodes) 22 } 23 24 extras = config |> HTML.build_extras(".xhtml") |> group_extras() 25 config = %{config | extras: extras} 26 27 assets_dir = "OEBPS/assets" 28 static_files = HTML.generate_assets(config, assets_dir, default_assets(config)) 29 HTML.generate_logo(assets_dir, config) 30 HTML.generate_cover(assets_dir, config) 31 32 uuid = "urn:uuid:#{uuid4()}" 33 datetime = format_datetime() 34 35 generate_content(config, nodes_map, uuid, datetime, static_files) 36 generate_nav(config, nodes_map) 37 generate_title(config) 38 generate_extras(config) 39 generate_list(config, nodes_map.modules) 40 generate_list(config, nodes_map.tasks) 41 42 {:ok, epub} = generate_epub(config.output) 43 File.rm_rf!(config.output) 44 Path.relative_to_cwd(epub) 45 end 46 47 defp normalize_config(config) do 48 output = 49 config.output 50 |> Path.expand() 51 |> Path.join("#{config.project}") 52 53 %{config | output: output} 54 end 55 56 defp generate_extras(config) do 57 for {_title, extras} <- config.extras do 58 Enum.each(extras, fn %{id: id, title: title, title_content: title_content, content: content} -> 59 output = "#{config.output}/OEBPS/#{id}.xhtml" 60 html = Templates.extra_template(config, title, title_content, content) 61 62 if File.regular?(output) do 63 IO.puts(:stderr, "warning: file #{Path.relative_to_cwd(output)} already exists") 64 end 65 66 File.write!(output, html) 67 end) 68 end 69 end 70 71 defp generate_content(config, nodes, uuid, datetime, static_files) do 72 static_files = 73 static_files 74 |> Enum.filter(fn name -> 75 String.contains?(name, "OEBPS") and config.output |> Path.join(name) |> File.regular?() 76 end) 77 |> Enum.map(&Path.relative_to(&1, "OEBPS")) 78 79 content = Templates.content_template(config, nodes, uuid, datetime, static_files) 80 File.write("#{config.output}/OEBPS/content.opf", content) 81 end 82 83 defp generate_nav(config, nodes) do 84 content = Templates.nav_template(config, nodes) 85 File.write("#{config.output}/OEBPS/nav.xhtml", content) 86 end 87 88 defp group_extras(extras) do 89 {extras_by_group, groups} = 90 extras 91 |> Enum.with_index() 92 |> Enum.reduce({%{}, %{}}, fn {x, index}, {extras_by_group, groups} -> 93 group = if x.group != "", do: x.group, else: "Extras" 94 extras_by_group = Map.update(extras_by_group, group, [x], &[x | &1]) 95 groups = Map.put_new(groups, group, index) 96 {extras_by_group, groups} 97 end) 98 99 groups 100 |> Map.to_list() 101 |> List.keysort(1) 102 |> Enum.map(fn {k, _} -> {k, Enum.reverse(Map.get(extras_by_group, k))} end) 103 end 104 105 defp generate_title(config) do 106 content = Templates.title_template(config) 107 File.write("#{config.output}/OEBPS/title.xhtml", content) 108 end 109 110 defp generate_list(config, nodes) do 111 nodes 112 |> Task.async_stream(&generate_module_page(&1, config), timeout: :infinity) 113 |> Enum.map(&elem(&1, 1)) 114 end 115 116 defp generate_epub(output) do 117 :zip.create( 118 String.to_charlist("#{output}.epub"), 119 [{~c"mimetype", @mimetype} | files_to_add(output)], 120 compress: [ 121 ~c".css", 122 ~c".xhtml", 123 ~c".html", 124 ~c".ncx", 125 ~c".js", 126 ~c".opf", 127 ~c".jpg", 128 ~c".png", 129 ~c".xml" 130 ] 131 ) 132 end 133 134 ## Helpers 135 136 defp default_assets(config) do 137 [ 138 {Assets.dist(config.proglang), "OEBPS/dist"}, 139 {Assets.metainfo(), "META-INF"} 140 ] 141 end 142 143 defp files_to_add(path) do 144 Enum.reduce(Path.wildcard(Path.join(path, "**/*")), [], fn file, acc -> 145 case File.read(file) do 146 {:ok, bin} -> 147 [{file |> Path.relative_to(path) |> String.to_charlist(), bin} | acc] 148 149 {:error, _} -> 150 acc 151 end 152 end) 153 end 154 155 # Helper to format Erlang datetime tuple 156 defp format_datetime do 157 {{year, month, day}, {hour, min, sec}} = :calendar.universal_time() 158 list = [year, month, day, hour, min, sec] 159 160 "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ" 161 |> :io_lib.format(list) 162 |> IO.iodata_to_binary() 163 end 164 165 defp generate_module_page(module_node, config) do 166 content = Templates.module_page(config, module_node) 167 File.write("#{config.output}/OEBPS/#{module_node.id}.xhtml", content) 168 end 169 170 # Helper to generate an UUID v4. This version uses pseudo-random bytes generated by 171 # the `crypto` module. 172 defp uuid4 do 173 <<u0::48, _::4, u1::12, _::2, u2::62>> = :crypto.strong_rand_bytes(16) 174 bin = <<u0::48, 4::4, u1::12, 2::2, u2::62>> 175 <<u0::32, u1::16, u2::16, u3::16, u4::48>> = bin 176 177 Enum.map_join( 178 [<<u0::32>>, <<u1::16>>, <<u2::16>>, <<u3::16>>, <<u4::48>>], 179 <<45>>, 180 &Base.encode16(&1, case: :lower) 181 ) 182 end 183 end