v0.0.6: PDF support & importing activation backup support

This commit is contained in:
Florian Bach 2021-09-28 18:43:14 +02:00
parent b927a549c6
commit ac655fdd9d
6 changed files with 279 additions and 34 deletions

View File

@ -1,6 +1,6 @@
# Calibre ACSM plugin
This is a Calibre plugin that allows you to turn ACSM files into EPUBs without the need for ADE.
This is a Calibre plugin that allows you to turn ACSM files into EPUB or PDF files without the need for ADE.
It is a full Python reimplementation of libgourou by Grégory Soutadé (http://indefero.soutade.fr/p/libgourou/).
## Setup
@ -12,15 +12,16 @@ It is a full Python reimplementation of libgourou by Grégory Soutadé (http://i
5. The settings window should now say "Authorized with ADE ID X on device Y".
6. Click the "Export account activation data" and "Export account encryption key" buttons to export / backup your keys. Do not skip this step. The first file (ZIP) can be used to re-authorize Calibre after a reset / reinstall without using up one of your Adobe authorizations. The second file (DER) can be imported into DeDRM.
7. If needed (new AdobeID), import the DER file into the DeDRM plugin.
8. Download an EPUB ACSM file from Adobe's test library and see if you can import it into Calibre: https://www.adobe.com/de/solutions/ebook/digital-editions/sample-ebook-library.html
8. Download an ACSM file from Adobe's test library and see if you can import it into Calibre: https://www.adobe.com/de/solutions/ebook/digital-editions/sample-ebook-library.html
IMPORTANT:
- I would suggest creating a new dummy AdobeID to use for Calibre so just in case Adobe detects this and bans you, you don't lose your main AdobeID.
- Combined with that I suggest importing the DER file into the DeDRM plugin to make sure that losing your AdobeID doesn't also mean you'll lose access to all your eBooks.
- This plugin doesn't yet work with PDFs. Importing an ACSM file for a PDF book will just result in the ACSM file being imported, it won't be converted into a PDF.
- Support for PDFs might be unreliable. You will need to apply pull request #1689 (including my additional bugfix in the comments of that PR) to the DeDRM plugin in order to remove the DRM from PDF files. If you still encounter an issue with a PDF file created by this tool even with these bugfixes, please report a bug and attach the corrupted PDF (in this repository, not in the DeDRM one).
- This software is not approved by Adobe. I am not responsible if Adobe detects that you're using nonstandard software and bans your account. Do not complain to me if Adobe bans your main ADE account - you have been warned.
## Standalone version
In the folder "calibre-plugin" in this repo (or inside the Calibre plugin ZIP file) there's some scripts that can also be used standalone without Calibre. If you want to use these, you need to extract the whole ZIP file.
@ -37,7 +38,5 @@ There's a bunch of features that could still be added, but most of them aren't i
- Support for anonymous Adobe IDs
- Support for un-authorizing a machine
- Support to re-import a backed-up authorization after a reinstallation (right now you can only do that manually)
- Support for PDFs
- Support for returning loan books
- ...

View File

@ -10,10 +10,11 @@
# v0.0.3: Standalone Calibre plugin for Linux, Windows, MacOS without the need for libgourou.
# v0.0.4: Manually execute DeDRM (if installed) after converting ACSM to EPUB.
# v0.0.5: Bugfix: DeDRM plugin was also executed if it's installed but disabled.
# v0.0.6: First PDF support, allow importing previously exported activation data.
from calibre.customize import FileTypePlugin # type: ignore
__version__ = '0.0.5'
__version__ = '0.0.6'
PLUGIN_NAME = "DeACSM"
PLUGIN_VERSION_TUPLE = tuple([int(x) for x in __version__.split(".")])
@ -175,13 +176,22 @@ class DeACSM(FileTypePlugin):
print("{0} v{1}: Error while importing Fulfillment stuff".format(PLUGIN_NAME, PLUGIN_VERSION))
traceback.print_exc()
try:
from calibre_plugins.deacsm.libpdf import patch_drm_into_pdf, prepare_string_from_xml
except:
try:
from libpdf import patch_drm_into_pdf, prepare_string_from_xml
except:
print("{0} v{1}: Error while importing PDF patch".format(PLUGIN_NAME, PLUGIN_VERSION))
traceback.print_exc()
adobe_fulfill_response = etree.fromstring(replyData)
NSMAP = { "adept" : "http://ns.adobe.com/adept" }
adNS = lambda tag: '{%s}%s' % ('http://ns.adobe.com/adept', tag)
adDC = lambda tag: '{%s}%s' % ('http://purl.org/dc/elements/1.1/', tag)
metadata_node = adobe_fulfill_response.find("./%s/%s/%s" % (adNS("fulfillmentResult"), adNS("resourceItemInfo"), adNS("metadata")))
download_url = adobe_fulfill_response.find("./%s/%s/%s" % (adNS("fulfillmentResult"), adNS("resourceItemInfo"), adNS("src"))).text
license_token_node = adobe_fulfill_response.find("./%s/%s/%s" % (adNS("fulfillmentResult"), adNS("resourceItemInfo"), adNS("licenseToken")))
@ -205,6 +215,19 @@ class DeACSM(FileTypePlugin):
filename = self.temporary_file(filetype).name
author = "None"
title = "None"
try:
title = metadata_node.find("./%s" % (adDC("title"))).text
author = metadata_node.find("./%s" % (adDC("creator"))).text
title = title.replace("(", "").replace(")", "").replace("/", "")
author = author.replace("(", "").replace(")", "").replace("/", "")
except:
pass
# Store book:
f = open(filename, "wb")
f.write(book_content)
@ -219,13 +242,13 @@ class DeACSM(FileTypePlugin):
return filename
elif filetype == ".pdf":
print("Successfully downloaded PDF, but PDF encryption is not yet supported")
print("You will not be able to use the downloaded PDF file")
print("Here's the raw string:")
print(rights_xml_str)
return None
print("{0} v{1}: Downloaded PDF, adding encryption config ...".format(PLUGIN_NAME, PLUGIN_VERSION))
pdf_tmp_file = self.temporary_file(filetype).name
patch_drm_into_pdf(filename, prepare_string_from_xml(rights_xml_str, author, title), pdf_tmp_file)
print("{0} v{1}: File successfully fulfilled ...".format(PLUGIN_NAME, PLUGIN_VERSION))
return pdf_tmp_file
else:
print("Error: Weird filetype")
print("{0} v{1}: Error: Unsupported file type ...".format(PLUGIN_NAME, PLUGIN_VERSION))
return None
def run(self, path_to_ebook: str):

View File

@ -16,7 +16,7 @@ from PyQt5 import Qt as QtGui
from zipfile import ZipFile
# calibre modules and constants.
from calibre.gui2 import (question_dialog, error_dialog, info_dialog, choose_save_file) # type: ignore
from calibre.gui2 import (question_dialog, error_dialog, info_dialog, choose_save_file, choose_files) # type: ignore
# modules from this plugin's zipfile.
from calibre_plugins.deacsm.__init__ import PLUGIN_NAME, PLUGIN_VERSION # type: ignore
import calibre_plugins.deacsm.prefs as prefs # type: ignore
@ -47,7 +47,7 @@ class ConfigWidget(QWidget):
ua_group_box_layout = QVBoxLayout()
ua_group_box.setLayout(ua_group_box_layout)
info_string, activated = self.get_account_info()
info_string, activated, mail = self.get_account_info()
self.lblAccInfo = QtGui.QLabel(self)
self.lblAccInfo.setText(info_string)
@ -59,6 +59,11 @@ class ConfigWidget(QWidget):
self.button_link_account.clicked.connect(self.link_account)
ua_group_box_layout.addWidget(self.button_link_account)
self.button_import_activation = QtGui.QPushButton(self)
self.button_import_activation.setText(_("Import existing activation data (ZIP)"))
self.button_import_activation.clicked.connect(self.import_activation)
ua_group_box_layout.addWidget(self.button_import_activation)
self.button_export_key = QtGui.QPushButton(self)
self.button_export_key.setText(_("Export account encryption key"))
self.button_export_key.clicked.connect(self.export_key)
@ -71,6 +76,7 @@ class ConfigWidget(QWidget):
self.button_export_activation.setEnabled(activated)
ua_group_box_layout.addWidget(self.button_export_activation)
try:
from calibre_plugins.deacsm.libadobe import VAR_HOBBES_VERSION, createDeviceKeyFile, update_account_path
from calibre_plugins.deacsm.libadobeAccount import createDeviceFile, createUser, signIn, activateDevice
@ -97,7 +103,7 @@ class ConfigWidget(QWidget):
container = etree.parse(activation_xml_path)
containerdev = etree.parse(device_xml_path)
except (FileNotFoundError, OSError) as e:
return "Not authorized for any ADE ID", False
return "Not authorized for any ADE ID", False, None
try:
adeptNS = lambda tag: '{%s}%s' % ('http://ns.adobe.com/adept', tag)
@ -108,20 +114,21 @@ class ConfigWidget(QWidget):
ade_device_name = devicenameXML.text
if container.find(adeptNS("activationToken")) == None:
return "ADE authorization seems to be corrupted (activationToken missing)", False
return "ADE authorization seems to be corrupted (activationToken missing)", False, None
if container.find(adeptNS("credentials")).find(adeptNS("pkcs12")) == None:
return "ADE authorization seems to be corrupted (pkcs12 missing)", False
return "ADE authorization seems to be corrupted (pkcs12 missing)", False, None
return "Authorized with ADE ID ("+ade_type+") " + ade_mail + "\non device " + ade_device_name, True
return "Authorized with ADE ID ("+ade_type+") " + ade_mail + "\non device " + ade_device_name, True, ade_mail
except:
return "ADE authorization seems to be corrupted", False
return "ADE authorization seems to be corrupted", False, None
def export_activation(self):
filters = [("ZIP", ["zip"])]
filename = choose_save_file(self, "Export ADE activation files", _("Export ADE activation files"), filters, all_files=False)
filename = choose_save_file(self, "Export ADE activation files", _("Export ADE activation files"),
filters, all_files=False, initial_filename="adobe_account_backup.zip")
if (filename is None):
return
@ -136,6 +143,57 @@ class ConfigWidget(QWidget):
except:
return error_dialog(None, "Export failed", "Export failed.", show=True, show_copy_button=False)
def import_activation(self):
filters = [("ZIP", ["zip"])]
filenames = choose_files(self, "Import ADE activation file (ZIP)", _("Import ADE activation file (ZIP)"),
filters, all_files=False, select_only_single_file=True)
try:
filename = filenames[0]
if (filename is None):
return
except:
return
print("{0} v{1}: Importing activation data from {2}".format(PLUGIN_NAME, PLUGIN_VERSION, filename))
with ZipFile(filename, 'r') as zipfile:
try:
device = zipfile.read("device.xml")
activation = zipfile.read("activation.xml")
salt = zipfile.read("devicesalt")
except:
return error_dialog(None, "Import failed", "Can't find required files in this ZIP")
try:
output_device = open(os.path.join(self.deacsmprefs["path_to_account_data"], "device.xml"), "w")
output_device.write(device.decode("utf-8"))
output_device.close()
output_activation = open(os.path.join(self.deacsmprefs["path_to_account_data"], "activation.xml"), "w")
output_activation.write(activation.decode("utf-8"))
output_activation.close()
output_salt = open(os.path.join(self.deacsmprefs["path_to_account_data"], "devicesalt"), "wb")
output_salt.write(salt)
output_salt.close()
except:
err = traceback.print_exc()
return error_dialog(None, "Import failed", "Can't write file", show=True, det_msg=err, show_copy_button=False)
# update display
info_string, activated, ade_mail = self.get_account_info()
self.lblAccInfo.setText(info_string)
self.button_link_account.setEnabled(not activated)
self.button_import_activation.setEnabled(not activated)
self.button_export_key.setEnabled(activated)
self.button_export_activation.setEnabled(activated)
info_dialog(None, "Done", "Successfully imported authorization for " + ade_mail, show=True, show_copy_button=False)
def link_account(self):
@ -181,10 +239,11 @@ class ConfigWidget(QWidget):
# update display
info_string, activated = self.get_account_info()
info_string, activated, mail = self.get_account_info()
self.lblAccInfo.setText(info_string)
self.button_link_account.setEnabled(False)
self.button_import_activation.setEnabled(False)
self.button_export_key.setEnabled(True)
self.button_export_activation.setEnabled(True)

View File

@ -7,7 +7,7 @@ This is an experimental Python version of libgourou.
# pyright: reportUndefinedVariable=false
import sys
import sys, os
if sys.version_info[0] < 3:
print("This script requires Python 3.")
exit(1)
@ -17,6 +17,7 @@ from lxml import etree
from libadobe import sendHTTPRequest
from libadobeFulfill import buildRights, fulfill
from libpdf import patch_drm_into_pdf, prepare_string_from_xml
FILE_DEVICEKEY = "devicesalt"
FILE_DEVICEXML = "device.xml"
@ -32,6 +33,8 @@ def download(replyData):
adNS = lambda tag: '{%s}%s' % ('http://ns.adobe.com/adept', tag)
adDC = lambda tag: '{%s}%s' % ('http://purl.org/dc/elements/1.1/', tag)
print (replyData)
metadata_node = adobe_fulfill_response.find("./%s/%s/%s" % (adNS("fulfillmentResult"), adNS("resourceItemInfo"), adNS("metadata")))
download_url = adobe_fulfill_response.find("./%s/%s/%s" % (adNS("fulfillmentResult"), adNS("resourceItemInfo"), adNS("src"))).text
@ -45,13 +48,27 @@ def download(replyData):
exit(1)
book_name = None
author = "None"
title = "None"
try:
book_name = metadata_node.find("./%s" % (adDC("title"))).text
except:
book_name = "Book"
try:
title = metadata_node.find("./%s" % (adDC("title"))).text
author = metadata_node.find("./%s" % (adDC("creator"))).text
title = title.replace("(", "").replace(")", "").replace("/", "")
author = author.replace("(", "").replace(")", "").replace("/", "")
except:
pass
# Download eBook:
print(download_url)
book_content = sendHTTPRequest(download_url)
filetype = ".bin"
@ -75,14 +92,17 @@ def download(replyData):
zf.writestr("META-INF/rights.xml", rights_xml_str)
zf.close()
print("File successfully fulfilled!")
print("File successfully fulfilled to " + filename)
exit(0)
elif filetype == ".pdf":
print("Successfully downloaded PDF, but PDF encryption is not yet supported")
print("You will not be able to use the downloaded PDF file")
print("Here's the raw string:")
print(rights_xml_str)
exit(1)
print("Successfully downloaded PDF, patching encryption ...")
os.rename(filename, "tmp_" + filename)
patch_drm_into_pdf("tmp_" + filename, prepare_string_from_xml(rights_xml_str, author, title), filename)
os.remove("tmp_" + filename)
print("File successfully fulfilled to " + filename)
exit(0)
else:
print("Error: Weird filetype")
exit(1)

View File

@ -276,11 +276,8 @@ def fulfill(acsm_file):
mimetype = acsmxml.find("./%s/%s/%s" % (adNS("resourceItemInfo"), adNS("metadata"), dcNS("format"))).text
if (mimetype == "application/pdf"):
print("You're trying to fulfill a PDF file.")
print("While that's technically possible with this script, the script doesn't yet support embedding the DRM information into PDF files.")
print("This means the PDF would be unusable.")
print("Thus, PDF fulfillment is disabled for now.")
return False, "PDF not supported"
#print("You're trying to fulfill a PDF file.")
pass
elif (mimetype == "application/epub+zip"):
#print("Trying to fulfill an EPUB file ...")
pass

147
calibre-plugin/libpdf.py Normal file
View File

@ -0,0 +1,147 @@
import os, zlib, base64
from lxml import etree
def read_reverse_order(file_name):
# Open file for reading in binary mode
with open(file_name, 'rb') as read_obj:
# Move the cursor to the end of the file
read_obj.seek(0, os.SEEK_END)
# Get the current position of pointer i.e eof
pointer_location = read_obj.tell()
# Create a buffer to keep the last read line
buffer = bytearray()
# Loop till pointer reaches the top of the file
while pointer_location >= 0:
# Move the file pointer to the location pointed by pointer_location
read_obj.seek(pointer_location)
# Shift pointer location by -1
pointer_location = pointer_location -1
# read that byte / character
new_byte = read_obj.read(1)
# If the read byte is new line character then it means one line is read
if new_byte == b'\n':
# Fetch the line from buffer and yield it
yield buffer.decode()[::-1]
# Reinitialize the byte array to save next line
buffer = bytearray()
else:
# If last read character is not eol then add it in buffer
buffer.extend(new_byte)
# As file is read completely, if there is still data in buffer, then its the first line.
if len(buffer) > 0:
# Yield the first line too
yield buffer.decode()[::-1]
def deflate_and_base64_encode( string_val ):
zlibbed_str = zlib.compress( string_val )
compressed_string = zlibbed_str[2:-4]
return base64.b64encode( compressed_string )
def prepare_string_from_xml(xmlstring, title, author):
b64data = deflate_and_base64_encode(xmlstring.encode("utf-8")).decode("utf-8")
adobe_fulfill_response = etree.fromstring(xmlstring)
NSMAP = { "adept" : "http://ns.adobe.com/adept" }
adNS = lambda tag: '{%s}%s' % ('http://ns.adobe.com/adept', tag)
resource = adobe_fulfill_response.find("./%s/%s" % (adNS("licenseToken"), adNS("resource"))).text
return "<</Length 128/EBX_TITLE(%s)/Filter/EBX_HANDLER/EBX_AUTHOR(%s)/V 4/ADEPT_ID(%s)/EBX_BOOKID(%s)/ADEPT_LICENSE(%s)>>" % (title, author, resource, resource, b64data)
def patch_drm_into_pdf(filename_in, drm_string, filename_out):
ORIG_FILE = filename_in
trailer = ""
trailer_idx = 0
for line in read_reverse_order(ORIG_FILE):
trailer_idx += 1
trailer = line + "\n" + trailer
if (line == "trailer"):
if (trailer_idx > 20):
print("trailer_idx is very large (%d). Usually it's 10 or less. File might be corrupted." % trailer_idx)
break
r_encrypt_offs1 = 0
r_encrypt_offs2 = 0
root_str = None
next_startxref = False
startxref = None
for line in trailer.split('\n'):
#print(line)
if ("R/Encrypt" in line):
root_str = line
line_split = line.split(' ')
next = 0
for element in line_split:
if element == "R/Encrypt":
next = 2
continue
if next == 2:
r_encrypt_offs1 = element
next = 1
continue
if next == 1:
r_encrypt_offs2 = element
next = 0
continue
if "startxref" in line:
next_startxref = True
continue
if next_startxref:
startxref = line
next_startxref = False
continue
filesize_str = str(os.path.getsize(ORIG_FILE))
filesize_pad = filesize_str.zfill(10)
additional_data = "\r"
additional_data += r_encrypt_offs1 + " " + r_encrypt_offs2 + " " + "obj" + "\r"
additional_data += drm_string
additional_data += "\r"
additional_data += "endobj"
ptr = int(filesize_str) + len(additional_data)
additional_data += "\rxref\r" + r_encrypt_offs1 + " " + str((int(r_encrypt_offs2) + 1)) + "\r"
additional_data += filesize_pad + " 00000 n" + "\r\n"
additional_data += "trailer"
additional_data += "\r"
arr_root_str = root_str.split('/')
did_prev = False
for elem in arr_root_str:
if elem.startswith("Prev"):
did_prev = True
additional_data += "Prev " + startxref
#print("Replacing prev from '%s' to '%s'" % (elem, "Prev " + startxref))
elif elem.startswith("ID[<"):
additional_data += elem.replace("><", "> <")
else:
additional_data += elem
additional_data += "/"
if not did_prev:
# remove two >> at end
additional_data = additional_data[:-3]
additional_data += "/Prev " + startxref + ">>" + "/"
#print("Faking Prev %s" % startxref)
additional_data = additional_data[:-1]
additional_data += "\r" + "startxref\r" + str(ptr) + "\r" + "%%EOF"
inp = open(ORIG_FILE, "rb")
out = open(filename_out, "wb")
out.write(inp.read())
out.write(additional_data.encode("latin-1"))
inp.close()
out.close()