first version
This commit is contained in:
commit
70d91723b2
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
# test epub files
|
||||||
|
*.epub
|
69
convert.py
Normal file
69
convert.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import argparse
|
||||||
|
import zipfile
|
||||||
|
import opencc
|
||||||
|
import glob
|
||||||
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# only initailize OpenCC once, or it would be very slow
|
||||||
|
converter = opencc.OpenCC(config="s2twp.json")
|
||||||
|
|
||||||
|
def convert_epub(epub, output=None):
|
||||||
|
target_filetype = ["htm", "html", "xhtml", "ncx", "opf"]
|
||||||
|
|
||||||
|
origin = zipfile.ZipFile(epub, mode="r")
|
||||||
|
copy = zipfile.ZipFile(output, mode="w")
|
||||||
|
|
||||||
|
for i, fn in enumerate(origin.namelist()):
|
||||||
|
info = origin.getinfo(fn)
|
||||||
|
extension = Path(fn).suffix[1:] # remove heading `.`
|
||||||
|
if extension in target_filetype:
|
||||||
|
# if file extension is targeted file type
|
||||||
|
sc_content = origin.read(fn)
|
||||||
|
tc_content = convert_content(sc_content)
|
||||||
|
if extension == "opf":
|
||||||
|
tc_content = tc_content.replace("<dc:language>zh-CN</dc:language>", "<dc:language>zh-TW</dc:language>")
|
||||||
|
copy.writestr(s2t(fn), tc_content, compress_type=info.compress_type)
|
||||||
|
else:
|
||||||
|
# write other files directly
|
||||||
|
copy.writestr(s2t(fn), origin.read(fn), compress_type=info.compress_type)
|
||||||
|
|
||||||
|
origin.close()
|
||||||
|
copy.close()
|
||||||
|
return output
|
||||||
|
|
||||||
|
def convert_content(content):
|
||||||
|
_tmp = []
|
||||||
|
|
||||||
|
for line in content.splitlines():
|
||||||
|
_tmp.append(s2t(line))
|
||||||
|
|
||||||
|
return "\n".join(_tmp)
|
||||||
|
|
||||||
|
def s2t(text):
|
||||||
|
return converter.convert(text)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Convert simplified chinese to traditional chinese in epub.")
|
||||||
|
parser.add_argument('file', nargs='+', help="epub files")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if len(args.file) == 1 and "*" in args.file[0]:
|
||||||
|
fn_list = glob.glob(args.file[0])
|
||||||
|
else:
|
||||||
|
fn_list = args.file
|
||||||
|
|
||||||
|
for fn in fn_list:
|
||||||
|
if not Path(fn).suffix == ".epub":
|
||||||
|
print(f"Skipping file {fn}, which is not an epub document.")
|
||||||
|
elif fn == s2t(fn):
|
||||||
|
print(f"Skipping file {fn}, which has already been converted.")
|
||||||
|
else:
|
||||||
|
t = time.time()
|
||||||
|
print(f"Converting {fn}")
|
||||||
|
buffer = BytesIO()
|
||||||
|
output = convert_epub(fn, buffer)
|
||||||
|
with open(s2t(fn), "wb") as f:
|
||||||
|
f.write(buffer.getvalue())
|
||||||
|
print(f"File {fn} is successfully converted. Time elapsed: {round(time.time() - t, 2)}s")
|
52
static/main.css
Normal file
52
static/main.css
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
body, html {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, #main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close.button {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
right: 2em;
|
||||||
|
top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dragzone[data-mode="selecting"] .close.button,
|
||||||
|
#dragzone:not([data-mode="selected"]) #submitbtn,
|
||||||
|
#dragzone:not([data-mode="converted"]) #downloadbtn,
|
||||||
|
#dragzone:not([data-mode^="upload"]) .ts.progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main {
|
||||||
|
flex: 1;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dragzone {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progressbar .bar {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload {
|
||||||
|
visibility: hidden;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit {
|
||||||
|
z-index: 5;
|
||||||
|
}
|
212
static/upload.js
Normal file
212
static/upload.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
const CancelToken = axios.CancelToken;
|
||||||
|
let cancel;
|
||||||
|
|
||||||
|
const dqs = (selector, ctx = document) => {
|
||||||
|
return ctx.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
HTMLElement.prototype.on = function(event, callback) {
|
||||||
|
this.addEventListener(event, callback);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFile(files) {
|
||||||
|
let filename = files[0].name;
|
||||||
|
let size = files[0].size;
|
||||||
|
|
||||||
|
// check file extension
|
||||||
|
if (filename.split(".").pop() != "epub") {
|
||||||
|
ts(".ts.snackbar").snackbar({
|
||||||
|
content: "只接受 EPUB 格式的檔案!"
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check file size
|
||||||
|
if (size >= sizeLimit) {
|
||||||
|
ts(".ts.snackbar").snackbar({
|
||||||
|
content: "檔案過大!"
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dqs("#upload").files.length) {
|
||||||
|
dqs("#upload").files = files;
|
||||||
|
}
|
||||||
|
|
||||||
|
dqs(".header", dqs("#dragzone")).textContent = filename;
|
||||||
|
dqs(".description", dqs("#dragzone")).textContent = `檔案大小: ${humanFileSize(size, false)}`;
|
||||||
|
dqs("#dragzone").dataset.mode = "selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(ev) {
|
||||||
|
if (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
dqs(".header", dqs("#dragzone")).textContent = "上傳";
|
||||||
|
dqs(".description", dqs("#dragzone")).innerHTML = "將檔案拖拉至此處進行上傳,或是點擊此處選取檔案。<br>Max upload size : " + humanFileSize(sizeLimit, false);
|
||||||
|
dqs("#dragzone").dataset.mode = "selecting";
|
||||||
|
dqs("#upload").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/14919494
|
||||||
|
function humanFileSize(bytes, si) {
|
||||||
|
var thresh = si ? 1000 : 1024;
|
||||||
|
if(Math.abs(bytes) < thresh) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
var units = si
|
||||||
|
? ['kB','MB','GB','TB','PB','EB','ZB','YB']
|
||||||
|
: ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB'];
|
||||||
|
var u = -1;
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
|
||||||
|
return bytes.toFixed(1)+' '+units[u];
|
||||||
|
}
|
||||||
|
|
||||||
|
dqs("#upload").on("change", ev => {
|
||||||
|
let el = ev.target;
|
||||||
|
if (el.files.length) {
|
||||||
|
if (el.files.length > 1) {
|
||||||
|
ts('.snackbar').snackbar({
|
||||||
|
content: "一次僅可上傳一個檔案。"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateFile(el.files);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dqs(".ts.close.button").on("click", ev => {
|
||||||
|
if (dqs("#dragzone").dataset.mode == "uploading") {
|
||||||
|
if (cancel) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
dqs("#submitbtn").on("click", ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
dqs("#dragzone").dataset.mode = "uploading";
|
||||||
|
|
||||||
|
// clean up styles
|
||||||
|
["preparing", "positive", "negative"].forEach(c => {
|
||||||
|
dqs("#progressbar").classList.toggle(c, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
dqs("#progressbar .bar").style.width = "0";
|
||||||
|
if (dqs("#downloadbtn").href) {
|
||||||
|
window.URL.revokeObjectURL(dqs("#downloadbtn").href);
|
||||||
|
dqs("#downloadbtn").href = "";
|
||||||
|
dqs("#downloadbtn").removeAttribute("download");
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.post("./api/convert", new FormData(document.form), {
|
||||||
|
responseType: "blob",
|
||||||
|
cancelToken: new CancelToken(function (executor) {
|
||||||
|
cancel = executor;
|
||||||
|
}),
|
||||||
|
onUploadProgress: (ev) => {
|
||||||
|
percentage = (ev.loaded / ev.total) * 100
|
||||||
|
dqs("#progressbar .bar").style.width = percentage + "%";
|
||||||
|
if (percentage == 100) {
|
||||||
|
dqs("#progressbar").classList.add("preparing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).then(function (res) {
|
||||||
|
dqs("#dragzone").dataset.mode = "converted";
|
||||||
|
dqs("#progressbar").classList.remove("preparing");
|
||||||
|
|
||||||
|
let blob = new Blob([res.data], { type: "application/epub+zip" });
|
||||||
|
let disposition = res.headers['content-disposition'];
|
||||||
|
let filename = disposition.slice(disposition.lastIndexOf("=") + 1, disposition.length);
|
||||||
|
if (filename.startsWith("UTF-8''")) {
|
||||||
|
filename = decodeURIComponent(filename.slice(7, filename.length));
|
||||||
|
}
|
||||||
|
dqs("#downloadbtn").href = window.URL.createObjectURL(blob);
|
||||||
|
dqs("#downloadbtn").setAttribute("download", filename);
|
||||||
|
}).catch(function (e) {
|
||||||
|
dqs("#dragzone").dataset.mode = "uploadend";
|
||||||
|
dqs("#progressbar").classList.remove("preparing");
|
||||||
|
dqs("#progressbar").classList.add("negative");
|
||||||
|
if (e.response) {
|
||||||
|
if (e.response.data instanceof Blob && e.response.data.type == "application/json") {
|
||||||
|
let reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
let data = JSON.parse(this.result);
|
||||||
|
ts(".snackbar").snackbar({
|
||||||
|
content: `錯誤: ${data.error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reader.readAsText(e.response.data);
|
||||||
|
}
|
||||||
|
} else if (axios.isCancel(e)) {
|
||||||
|
console.log("Upload progress canceled");
|
||||||
|
dqs("#progressbar").classList.remove("negative");
|
||||||
|
ts(".snackbar").snackbar({
|
||||||
|
content: "上傳已取消"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dqs("#dragzone").on("click", ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (dqs("#dragzone").dataset.mode != "uploading") {
|
||||||
|
let allowlist = ["button", "a"];
|
||||||
|
if (allowlist.indexOf(ev.target.tagName.toLowerCase()) == -1) {
|
||||||
|
dqs("#upload").click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dqs("#downloadbtn").on("click", ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
let el = ev.target;
|
||||||
|
|
||||||
|
let link = document.createElement("a");
|
||||||
|
link.setAttribute("download", el.getAttribute("download"));
|
||||||
|
link.style.display = "none";
|
||||||
|
link.href = el.href;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
dqs("#dragzone").on("drop", ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (dqs("#dragzone").dataset.mode != "uploading") {
|
||||||
|
let files = ev.dataTransfer.files;
|
||||||
|
if (files) {
|
||||||
|
if (files.length > 1) {
|
||||||
|
ts('.snackbar').snackbar({
|
||||||
|
content: "一次僅可上傳一個檔案。"
|
||||||
|
});
|
||||||
|
} else if (files.length == 1) {
|
||||||
|
updateFile(files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
["dragenter", "dragover"].forEach(event => {
|
||||||
|
dqs("#dragzone").on(event, ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
50
templates/index.html.j2
Normal file
50
templates/index.html.j2
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<!-- Tocas UI:CSS 與元件 -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/2.3.3/tocas.css">
|
||||||
|
<!-- Tocas JS:模塊與 JavaScript 函式 -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas-ui/2.3.3/tocas.js"></script>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='main.css') }}">
|
||||||
|
</head>
|
||||||
|
<title>EPUB Convert</title>
|
||||||
|
<body>
|
||||||
|
<form action="/api/convert" method="post" enctype="multipart/form-data" name="form" id="form">
|
||||||
|
<input type="file" name="upload" id="upload" accept=".epub" />
|
||||||
|
</form>
|
||||||
|
<div class="ts center aligned big header">
|
||||||
|
EPUB Convert
|
||||||
|
</div>
|
||||||
|
<div class="ts container" id="main">
|
||||||
|
<div class="ts basic padded dashed clickable slate" id="dragzone" data-mode="selecting">
|
||||||
|
<button class="ts close button"></button>
|
||||||
|
<i class="upload symbol icon"></i>
|
||||||
|
<span class="header">上傳</span>
|
||||||
|
<span class="description">將檔案拖拉至此處進行上傳,或是點擊此處選取檔案。<br>Max upload size : {{ limit_human_readable }}</span>
|
||||||
|
<div class="action">
|
||||||
|
<button class="ts primary button" id="submitbtn">Upload</button>
|
||||||
|
<a class="ts positive button" id="downloadbtn">Download</a>
|
||||||
|
<div class="ts progress" id="progressbar">
|
||||||
|
<div class="bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="ts padded attached center aligned inverted segment">
|
||||||
|
<p>Convert simplified chinese to traditional chinese in epub.</p>
|
||||||
|
<p>© Stone App | <a href="https://github.com/stoneapptech/epub_convert">GitHub Repo</a></p>
|
||||||
|
</footer>
|
||||||
|
<!-- snackbar -->
|
||||||
|
<div class="ts top right snackbar">
|
||||||
|
<div class="content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const sizeLimit = {{ limit }};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='upload.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
65
web.py
Normal file
65
web.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import tempfile
|
||||||
|
import hashlib
|
||||||
|
from io import BytesIO
|
||||||
|
from flask import (
|
||||||
|
Flask, jsonify, redirect, request, render_template, send_file, url_for, safe_join
|
||||||
|
)
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from convert import convert_epub, s2t
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['JSON_AS_ASCII'] = False
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
def human_file_size(bytes_count):
|
||||||
|
threshold = 1024
|
||||||
|
units = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||||
|
if bytes_count < threshold:
|
||||||
|
return f"{bytes_count} B"
|
||||||
|
|
||||||
|
ui = -1
|
||||||
|
while True:
|
||||||
|
bytes_count /= threshold
|
||||||
|
ui += 1
|
||||||
|
if bytes_count < threshold or ui == (len(units) - 1):
|
||||||
|
break
|
||||||
|
|
||||||
|
return f"{round(bytes_count, 1)} {units[ui]}"
|
||||||
|
|
||||||
|
@app.route("/", methods=["GET"])
|
||||||
|
def render_index():
|
||||||
|
limit = app.config["MAX_CONTENT_LENGTH"]
|
||||||
|
return render_template("index.html.j2", limit=limit, limit_human_readable=human_file_size(limit))
|
||||||
|
|
||||||
|
@app.route('/api/convert', methods=["POST"])
|
||||||
|
def upload_epub_sync():
|
||||||
|
if 'upload' not in request.files:
|
||||||
|
return jsonify({"status": False, "error": "No file is specified."}), 400
|
||||||
|
|
||||||
|
epub_file = request.files['upload']
|
||||||
|
|
||||||
|
if epub_file.filename == '':
|
||||||
|
return jsonify({"status": False, "error": "No file name."}), 400
|
||||||
|
|
||||||
|
# https://stackoverflow.com/questions/283707/size-of-an-open-file-object/283719#283719
|
||||||
|
epub_file.seek(0, 2)
|
||||||
|
end_position = epub_file.tell()
|
||||||
|
if end_position > app.config['MAX_CONTENT_LENGTH']:
|
||||||
|
return jsonify({"status": False, "error": f"File is too large. Maxium file size is {human_file_size(app.config['MAX_CONTENT_LENGTH'])}"}), 413
|
||||||
|
|
||||||
|
if epub_file and Path(epub_file.filename).suffix == ".epub":
|
||||||
|
output_buffer = BytesIO()
|
||||||
|
try:
|
||||||
|
_result = convert_epub(epub_file, output_buffer)
|
||||||
|
print(f"Converted Successfully. File: {s2t(epub_file.filename)}")
|
||||||
|
output_buffer.seek(0)
|
||||||
|
return send_file(output_buffer, as_attachment=True, attachment_filename=s2t(epub_file.filename))
|
||||||
|
except Exception as e:
|
||||||
|
error_class = e.__class__.__name__
|
||||||
|
return jsonify({"status": False, "error": error_class}), 500
|
||||||
|
else:
|
||||||
|
return jsonify({"status": False, "error": "Not an epub document"}), 415 # Unsupported Media Type
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", debug=True)
|
Loading…
x
Reference in New Issue
Block a user