diff --git a/pyproject.toml b/pyproject.toml index c978964..3166808 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.7" +colour = "^0.1.5" pdfrw = "^0.4" reportlab = "^3.5.59" svglib = "^1.0.1" diff --git a/rmrl/__main__.py b/rmrl/__main__.py index 9f9f4cf..7df74ad 100644 --- a/rmrl/__main__.py +++ b/rmrl/__main__.py @@ -19,16 +19,22 @@ import zipfile from . import render -from .constants import VERSION +from .constants import VERSION, HIGHLIGHT_DEFAULT_COLOR +from .render import InvalidColor from .sources import ZipSource def main(): - parser = argparse.ArgumentParser(description="Render a PDF file from a Remarkable document.") + parser = argparse.ArgumentParser(description="Render a PDF file from a Remarkable document.", + epilog='The colors may be specified as hex strings ("#AABBCC", "#ABC") or well-known names ("black", "red"). If no gray color is given, the program will use an average of the white and black colors. A fixed amount of transparency will be applied to the color given for the highlighter.') parser.add_argument('input', help="Filename of zip file, or root-level unpacked file of document. Use '-' to read zip file from stdin.") parser.add_argument('output', nargs='?', default='', help="Filename where PDF file should be written. Omit to write to stdout.") parser.add_argument('--alpha', default=0.3, help="Opacity for template background (0 for no background).") parser.add_argument('--no-expand', action='store_true', help="Don't expand pages to margins on device.") parser.add_argument('--only-annotated', action='store_true', help="Only render pages with annotations.") + parser.add_argument('--black', default='black', help='Color for "black" pen.') + parser.add_argument('--white', default='white', help='Color for "white" pen.') + parser.add_argument('--gray', '--grey', default=None, help='Color for "gray" pen.') + parser.add_argument('--highlight', '--hilight', '--hl', default=HIGHLIGHT_DEFAULT_COLOR, help='Color for the highlighter.') parser.add_argument('--version', action='version', version=VERSION) args = parser.parse_args() @@ -41,13 +47,21 @@ def main(): else: fout = sys.stdout.buffer - stream = render(source, - template_alpha=float(args.alpha), - expand_pages=not args.no_expand, - only_annotated=args.only_annotated) - fout.write(stream.read()) - fout.close() - return 0 + try: + stream = render(source, + template_alpha=float(args.alpha), + expand_pages=not args.no_expand, + only_annotated=args.only_annotated, + black=args.black, + white=args.white, + gray=args.gray, + highlight=args.highlight) + fout.write(stream.read()) + fout.close() + return 0 + except InvalidColor as e: + print(str(e), file=sys.stderr) + return 1 if __name__ == '__main__': sys.exit(main()) diff --git a/rmrl/constants.py b/rmrl/constants.py index 7d1d0f0..1e1f7f0 100644 --- a/rmrl/constants.py +++ b/rmrl/constants.py @@ -23,3 +23,6 @@ TEMPLATE_PATH = xdg_data_home() / 'rmrl' / 'templates' VERSION = pkg_resources.get_distribution('rmrl').version + +HIGHLIGHT_DEFAULT_COLOR = '#FFE949' +HIGHLIGHT_ALPHA = 0.392 diff --git a/rmrl/document.py b/rmrl/document.py index 14a2f91..8e1b5ac 100644 --- a/rmrl/document.py +++ b/rmrl/document.py @@ -29,10 +29,11 @@ class DocumentPage: # A single page in a document - def __init__(self, source, pid, pagenum): + def __init__(self, source, pid, pagenum, colors): # Page 0 is the first page! self.source = source self.num = pagenum + self.colors = colors # On disk, these files are named by a UUID self.rmpath = f'{{ID}}/{pid}.rm' @@ -103,7 +104,7 @@ def load_layers(self): except: name = 'Layer ' + str(i + 1) - layer = DocumentPageLayer(self, name=name) + layer = DocumentPageLayer(self, name=name, colors=self.colors) layer.strokes = layerstrokes self.layers.append(layer) @@ -152,17 +153,15 @@ def render_to_painter(self, canvas, vector, template_alpha): class DocumentPageLayer: pen_widths = [] - def __init__(self, page, name=None): + def __init__(self, page, colors, name=None): self.page = page self.name = name self.colors = [ - #QSettings().value('pane/notebooks/export_pdf_blackink'), - #QSettings().value('pane/notebooks/export_pdf_grayink'), - #QSettings().value('pane/notebooks/export_pdf_whiteink') - (0, 0, 0), - (0.5, 0.5, 0.5), - (1, 1, 1) + colors.black.rgb, + colors.gray.rgb, + colors.white.rgb, + colors.highlight.rgb, ] # Set this from the calling func @@ -233,6 +232,10 @@ def paint_strokes(self, canvas, vector): log.error("Unknown pen code %d" % pen) penclass = pens.GenericPen + # Hack to get the right color for the highlighter. + if penclass == pens.HighlighterPen: + color = -1 + qpen = penclass(vector=vector, layer=self, color=self.colors[color]) diff --git a/rmrl/pens/highlighter.py b/rmrl/pens/highlighter.py index ce45cdd..cd7a4f0 100644 --- a/rmrl/pens/highlighter.py +++ b/rmrl/pens/highlighter.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from ..constants import HIGHLIGHT_ALPHA from .generic import GenericPen class HighlighterPen(GenericPen): @@ -28,7 +29,7 @@ def paint_stroke(self, canvas, stroke): canvas.setLineCap(2) # Square canvas.setLineJoin(1) # Round #canvas.setDash ?? for solid line - canvas.setStrokeColor((1.000, 0.914, 0.290), alpha=0.392) + canvas.setStrokeColor(self.color, alpha=HIGHLIGHT_ALPHA) canvas.setLineWidth(stroke.width) path = canvas.beginPath() diff --git a/rmrl/render.py b/rmrl/render.py index 475b1da..205de77 100644 --- a/rmrl/render.py +++ b/rmrl/render.py @@ -19,6 +19,9 @@ from pathlib import Path import json import re +from collections import namedtuple + +from colour import Color from pdfrw import PdfReader, PdfWriter, PageMerge, PdfDict, PdfArray, PdfName, \ IndirectPdfDict, uncompress, compress @@ -26,18 +29,28 @@ from reportlab.pdfgen import canvas from . import document, sources -from .constants import PDFHEIGHT, PDFWIDTH, PTPERPX, SPOOL_MAX +from .constants import PDFHEIGHT, PDFWIDTH, PTPERPX, SPOOL_MAX, HIGHLIGHT_DEFAULT_COLOR log = logging.getLogger(__name__) +Colors = namedtuple('Colors', ['black', 'white', 'gray', 'highlight']) + +class InvalidColor(Exception): + """Raised when an invalid string is passed as a color.""" + pass + + def render(source, *, progress_cb=lambda x: None, expand_pages=True, template_alpha=0.3, - only_annotated=False): - """ - Render a source document as a PDF file. + only_annotated=False, + black='black', + white='white', + gray=None, + highlight=HIGHLIGHT_DEFAULT_COLOR): + """Render a source document as a PDF file. source: The reMarkable document to be rendered. This may be - A filename or pathlib.Path to a zip file containing the @@ -59,8 +72,19 @@ def render(source, *, makes the templates invisible, 1 makes them fully dark. only_annotated: Boolean value (default False) indicating whether only pages with annotations should be output. + black: A string giving the color to use as "black" in the document. + Can be a color name or a hex string. Default: 'black' + white: A string giving the color to use as "white" in the document. + See `black` parameter for format. Default: 'white' + gray: A string giving the color to use as "gray" in the document. + See `black` parameter for format. Default: None, which means to + pick an average between the "white" and "black" values. + highlight: A string giving the color to use for the highlighter. + See `black` parameter for format. """ + colors = parse_colors(black, white, gray, highlight) + vector=True # TODO: Different rendering styles source = sources.get_source(source) @@ -89,7 +113,7 @@ def render(source, *, changed_pages = [] annotations = [] for i in range(0, len(pages)): - page = document.DocumentPage(source, pages[i], i) + page = document.DocumentPage(source, pages[i], i, colors=colors) if source.exists(page.rmpath): changed_pages.append(i) page.render_to_painter(pdf_canvas, vector, template_alpha) @@ -177,6 +201,35 @@ def render(source, *, return stream +def parse_colors(black, white, gray, highlight): + black_color = parse_color(black, 'black') + white_color = parse_color(white, 'white') + highlight_color = parse_color(highlight, 'highlight') + + if gray is not None: + # Use the explicit gray value. + gray_color = parse_color(gray, 'gray') + elif black_color.saturation == 0 or white_color.saturation == 0: + # One or the other of the color endpoints is a shade of gray (or + # white or black). Use average in RGB space. This keeps the hue + # from the saturated endpoint and just lets the other endpoint + # either darken or lighten it. + gray_color = Color(rgb=((b + w) / 2 for b, w in zip(black_color.rgb, white_color.rgb))) + else: + # Both "black" and "white" have color elements to them. Use + # Color.range_to, which more or less averages in HSL space. + gray_color = list(black_color.range_to(white_color, 3))[1] + + return Colors(black=black_color, white=white_color, gray=gray_color, highlight=highlight_color) + + +def parse_color(color_string, name): + try: + return Color(color_string) + except Exception as e: + raise InvalidColor('"{}" color was passed an invalid string: {}'.format(name, str(e))) + + def do_apply_ocg(basepage, rmpage, i, uses_base_pdf, ocgprop, annotations): ocgpage = IndirectPdfDict( Type=PdfName('OCG'),