From 3d7f2a51ce2ee3cb8c9b2d472c371b6c7ef0c481 Mon Sep 17 00:00:00 2001 From: Neetpone <132411956+Neetpone@users.noreply.github.com> Date: Sat, 13 Apr 2024 20:43:09 -0400 Subject: [PATCH] feature: initial implementation of EPUB generation --- Gemfile | 1 + Gemfile.lock | 4 ++ app/controllers/chapters_controller.rb | 23 +------ app/lib/ebook/epub_generator.rb | 43 +++++++++++++ app/lib/ebook/files/styles.css | 72 ++++++++++++++++++++++ app/lib/ebook/templates/chapter.html.slim | 10 +++ app/lib/ebook/templates/cover.html.slim | 33 ++++++++++ test.epub | Bin 0 -> 6989 bytes 8 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 app/lib/ebook/epub_generator.rb create mode 100644 app/lib/ebook/files/styles.css create mode 100644 app/lib/ebook/templates/chapter.html.slim create mode 100644 app/lib/ebook/templates/cover.html.slim create mode 100644 test.epub diff --git a/Gemfile b/Gemfile index 868ef54..5bec36a 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,7 @@ gem 'puma', '~> 5.0' gem 'sidekiq' # Other stuff +gem 'gepub' gem 'marcel' # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index 6588638..2cd1098 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,9 @@ GEM faraday-net_http (>= 2.0, < 3.2) faraday-net_http (3.1.0) net-http + gepub (1.0.15) + nokogiri (>= 1.8.2, < 2.0) + rubyzip (> 1.1.1, < 2.4) globalid (1.2.1) activesupport (>= 6.1) hashie (5.0.0) @@ -305,6 +308,7 @@ DEPENDENCIES debug elasticsearch-model fancy_searchable! + gepub kaminari marcel model-msearch diff --git a/app/controllers/chapters_controller.rb b/app/controllers/chapters_controller.rb index c7dd82a..077644c 100644 --- a/app/controllers/chapters_controller.rb +++ b/app/controllers/chapters_controller.rb @@ -6,27 +6,6 @@ class ChaptersController < ApplicationController @story = Story.find(params[:story_id]) @chapter = @story.chapters.find_by(number: params[:id]) - @rendered_html = render_story - end - - private - - def render_story - body = @chapter.body - body.lstrip! - body = body.split "\n" - - # This is fucking bad, this gets rid of the redundant title - this should be fixed upstairs, - # in the actual generation of the Markdown. - if body.length >= 2 && body[0] == @chapter.title && !body[1].empty? && body[1][0] == '=' - body = body[2..] - end - - markdown.render body.join("\n") - end - - def markdown - @@markdown ||= - Redcarpet::Markdown.new(Redcarpet::Render::HTML, autolink: true, tables: true) + @rendered_html = StoryRenderer.render_chapter(@chapter) end end diff --git a/app/lib/ebook/epub_generator.rb b/app/lib/ebook/epub_generator.rb new file mode 100644 index 0000000..e0c1354 --- /dev/null +++ b/app/lib/ebook/epub_generator.rb @@ -0,0 +1,43 @@ +require 'gepub' + +TEMPLATE_DIRECTORY = Rails.root.join('app/lib/ebook') + +class Ebook::EpubGenerator + include ActionView::Helpers::TagHelper + + def initialize(story) + @story = story + end + + def generate + book = GEPUB::Book.new + book.add_title @story.title, title_type: GEPUB::TITLE_TYPE::MAIN, lang: :en, display_seq: 1 + book.add_creator @story.author.name + book.add_item 'styles.css', content: TEMPLATE_DIRECTORY.join('files/styles.css').open + + book.ordered do + book.add_item('CoverPage.html', content: generate_cover_page).landmark(type: 'cover', title: 'Cover Page') + @story.chapters.each do |chapter| + book.add_item("Chapter#{chapter.number}.html", content: generate_chapter(chapter)) + .toc_text("Chapter #{chapter.number} - #{chapter.title}") + .landmark(type: 'bodymatter', title: chapter.title) + end + end + + book + end + + #private + + def render_template(name, context) + Slim::Template.new(TEMPLATE_DIRECTORY.join("templates/#{name}.html.slim")).render(context) + end + + def generate_cover_page + StringIO.new render_template('cover', @story) + end + + def generate_chapter(chapter) + StringIO.new render_template('chapter', chapter) + end +end diff --git a/app/lib/ebook/files/styles.css b/app/lib/ebook/files/styles.css new file mode 100644 index 0000000..5f6835b --- /dev/null +++ b/app/lib/ebook/files/styles.css @@ -0,0 +1,72 @@ +.double { + margin-top: 1em; +} +.indented { + text-indent: 3em; +} +.i { + font-style: italic; +} +.u { + text-decoration: underline; +} +.b { + font-weight: bold; +} +.c { + text-align: center; +} +.s { + text-decoration: line-through; +} +img.emoticon { + margin: 0; + padding: 0; + border: 0; + height: 1em; +} +hr { + margin-top: 12px; +} +blockquote { + padding: 5px 10px; + margin: 10px; + border-left: 5px solid #aaa; + background: #eee; +} +.authors-note, +.preface { + border-top: 3px double #333; + border-bottom: 3px double #333; +} +.afterward { + border-top: 3px double #333; +} +h1 { + font-size: 1.9em; + margin: 0.8em 0; +} +h2 { + font-size: 1.3em; + margin: 0.5em 0; +} +h3 { + font-size: 0.625em; + margin: 0; +} +.authors-note, +.preface, +.afterward { + background: none; + border-left: none; + padding: 4px 0; +} +.authors-note h1, +.authors-note h2, +.preface h1, +.preface h2, +.afterward h1, +.afterward h2 { + margin: 0.5em 0; + font-size: 1.3em; +} diff --git a/app/lib/ebook/templates/chapter.html.slim b/app/lib/ebook/templates/chapter.html.slim new file mode 100644 index 0000000..797415f --- /dev/null +++ b/app/lib/ebook/templates/chapter.html.slim @@ -0,0 +1,10 @@ +doctype xml +html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" + head + meta http-equiv="Content-Type" content="text/html; charset=UTF-8" + link rel="stylesheet" href="styles.css" + title= title + body + h1= title + .calibre2#content + == StoryRenderer.render_chapter(self) \ No newline at end of file diff --git a/app/lib/ebook/templates/cover.html.slim b/app/lib/ebook/templates/cover.html.slim new file mode 100644 index 0000000..8fb36b2 --- /dev/null +++ b/app/lib/ebook/templates/cover.html.slim @@ -0,0 +1,33 @@ +doctype xml +html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" + head + meta http-equiv="Content-Type" content="text/html; charset=UTF-8" + link rel="stylesheet" href="styles.css" + title Cover + body + table style="width: 100%; height: 100%;" + tr + td + h1= title + p + b by #{author.name} + hr + p + | Rating: #{content_rating} + br + | Tags: #{tags.pluck(:name).join(', ')} + br + | Length: #{num_words} + hr + table.desc + tr style="height: 100%;" valign="top" + td + p.double= short_description + == description_html + tr.bottom + td + hr + p + | Published #{date_published} + br + | Last updated #{date_updated} \ No newline at end of file diff --git a/test.epub b/test.epub new file mode 100644 index 0000000000000000000000000000000000000000..57236bb7f1c8e2eb2f819798984f6c2cd533b4c5 GIT binary patch literal 6989 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 OcZ=Ln(ZwAC0Qf(Hby#o! literal 0 HcmV?d00001