diff --git a/README.md b/README.md index 172eb79..d11e370 100644 --- a/README.md +++ b/README.md @@ -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 - ... diff --git a/calibre-plugin/__init__.py b/calibre-plugin/__init__.py index 43dac23..2dd447a 100644 --- a/calibre-plugin/__init__.py +++ b/calibre-plugin/__init__.py @@ -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): diff --git a/calibre-plugin/config.py b/calibre-plugin/config.py index 449c5d2..6fd379d 100644 --- a/calibre-plugin/config.py +++ b/calibre-plugin/config.py @@ -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) diff --git a/calibre-plugin/fulfill.py b/calibre-plugin/fulfill.py index 8afbe5c..34fa82b 100644 --- a/calibre-plugin/fulfill.py +++ b/calibre-plugin/fulfill.py @@ -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) diff --git a/calibre-plugin/libadobeFulfill.py b/calibre-plugin/libadobeFulfill.py index 186cb5a..53c3dfc 100644 --- a/calibre-plugin/libadobeFulfill.py +++ b/calibre-plugin/libadobeFulfill.py @@ -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 diff --git a/calibre-plugin/libpdf.py b/calibre-plugin/libpdf.py new file mode 100644 index 0000000..862bb35 --- /dev/null +++ b/calibre-plugin/libpdf.py @@ -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 "<>" % (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()