├── tests ├── __init__.py ├── manconverterdata │ ├── input │ │ ├── simple.1 │ │ └── links.1 │ └── expected │ │ ├── simple.1.yaml │ │ └── links.1.yaml ├── rstconvertertestcase.py ├── tests.py ├── converterstestcase.py └── manconvertertestcase.py ├── screenshot.png ├── .gitignore ├── setup.cfg ├── mup ├── __init__.py ├── data │ ├── unsupported.html │ ├── placeholder.html │ └── template.html ├── converters │ ├── htmlconverter.py │ ├── rstconverter.py │ ├── markdownconverter.py │ ├── converter.py │ ├── ghmarkdown.py │ ├── __init__.py │ ├── processconverter.py │ ├── utils.py │ └── man.py ├── config.py ├── mupman.py ├── converterthread.py ├── main.py ├── history.py ├── findwidget.py ├── view.py └── window.py ├── share ├── mup │ └── converters │ │ ├── man.yaml │ │ ├── pandoc.yaml │ │ ├── commonmark.yaml │ │ ├── gruber.yaml │ │ ├── ronn.yaml │ │ ├── mediawiki.yaml │ │ ├── asciidoc.yaml │ │ ├── gfm.yaml │ │ ├── gh-markdown.yaml │ │ └── gh-gfm.yaml └── applications │ └── mup.desktop ├── examples ├── md │ ├── another-page.md │ └── example.md ├── man │ └── fixworld.1 ├── ronn │ └── fixworld.ronn ├── rst │ └── example.rst └── asciidoc │ └── example.txt ├── testinst.sh ├── setup.py ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateau/mup/HEAD/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | build 4 | dist 5 | *.egg-info 6 | install.log 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [install] 2 | single-version-externally-managed=1 3 | record=install.log 4 | -------------------------------------------------------------------------------- /mup/__init__.py: -------------------------------------------------------------------------------- 1 | __appname__ = "mup" 2 | __version__ = "1.0.0" 3 | __license__ = "BSD" 4 | -------------------------------------------------------------------------------- /share/mup/converters/man.yaml: -------------------------------------------------------------------------------- 1 | name: Man 2 | cmd: mup-man 3 | args: 4 | full: true 5 | matches: ["*.[1-9]"] 6 | -------------------------------------------------------------------------------- /examples/md/another-page.md: -------------------------------------------------------------------------------- 1 | # Another Page 2 | 3 | This is another page. Go back to [example.md](example.md). 4 | -------------------------------------------------------------------------------- /share/mup/converters/pandoc.yaml: -------------------------------------------------------------------------------- 1 | name: Pandoc 2 | cmd: pandoc 3 | matches: ["*.md", "*.mkd", "*.markdown", "README"] 4 | -------------------------------------------------------------------------------- /share/mup/converters/commonmark.yaml: -------------------------------------------------------------------------------- 1 | name: CommonMark 2 | cmd: stmd 3 | matches: ["*.md", "*.mkd", "*.markdown", "README"] 4 | -------------------------------------------------------------------------------- /share/mup/converters/gruber.yaml: -------------------------------------------------------------------------------- 1 | name: Gruber Markdown 2 | cmd: markdown 3 | matches: ["*.md", "*.mkd", "*.markdown", "README"] 4 | -------------------------------------------------------------------------------- /share/mup/converters/ronn.yaml: -------------------------------------------------------------------------------- 1 | name: Ronn 2 | cmd: ronn 3 | args: --pipe --fragment 4 | matches: ["*.ronn"] 5 | reference: true 6 | -------------------------------------------------------------------------------- /mup/data/unsupported.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | MUP does not know how to convert this file. 5 |

6 | 7 | 8 | -------------------------------------------------------------------------------- /share/mup/converters/mediawiki.yaml: -------------------------------------------------------------------------------- 1 | name: Mediawiki 2 | cmd: pandoc 3 | args: --from mediawiki 4 | matches: ["*.wiki", "*.mediawiki", "*.mw"] 5 | -------------------------------------------------------------------------------- /tests/manconverterdata/input/simple.1: -------------------------------------------------------------------------------- 1 | .TH EXAMPLE 1 2 | .SH NAME 3 | example \- an example 4 | .SH SYNOPSIS 5 | example 6 | .RB [ -f ] 7 | .RB [ -l ] 8 | -------------------------------------------------------------------------------- /share/mup/converters/asciidoc.yaml: -------------------------------------------------------------------------------- 1 | name: Asciidoc 2 | cmd: asciidoc 3 | args: --out-file - - 4 | full: true 5 | matches: ["*.txt"] 6 | reference: true 7 | -------------------------------------------------------------------------------- /tests/manconverterdata/expected/simple.1.yaml: -------------------------------------------------------------------------------- 1 | patterns: 2 | - NAME 3 | - example\s+-\s+an\s+example 4 | - SYNOPSIS 5 | - -f 6 | - -l 7 | -------------------------------------------------------------------------------- /share/mup/converters/gfm.yaml: -------------------------------------------------------------------------------- 1 | name: GitHub Flavored Markdown (Kramdown) 2 | cmd: kramdown 3 | args: --input GFM 4 | matches: ["*.md", "*.mkd", "*.markdown", "README"] 5 | -------------------------------------------------------------------------------- /share/mup/converters/gh-markdown.yaml: -------------------------------------------------------------------------------- 1 | name: GitHub Markdown (via github.com) 2 | cmd: mup-gh-markdown 3 | matches: ["*.md", "*.mkd", "*.markdown", "README"] 4 | online: true 5 | -------------------------------------------------------------------------------- /mup/data/placeholder.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | This page does not exist yet. 5 |

6 | 7 | Create it 8 | 9 | 10 | -------------------------------------------------------------------------------- /share/mup/converters/gh-gfm.yaml: -------------------------------------------------------------------------------- 1 | name: GitHub Flavored Markdown (via github.com) 2 | cmd: mup-gh-markdown 3 | args: --mode gfm 4 | matches: ["*.md", "*.mkd", "*.markdown", "README"] 5 | online: true 6 | -------------------------------------------------------------------------------- /share/applications/mup.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=MUP 3 | GenericName=Markup reader 4 | Comment=A markup reader 5 | Exec=mup %F 6 | Terminal=false 7 | Icon=text-x-generic 8 | Type=Application 9 | Categories=Qt;Viewer;Office; 10 | MimeType=text/x-markdown;text/x-rst; 11 | -------------------------------------------------------------------------------- /tests/manconverterdata/expected/links.1.yaml: -------------------------------------------------------------------------------- 1 | patterns: 2 | - foo\(1\) 3 | - bar\(3\) 4 | - xbaz\(1x\) 5 | - italic\(2\) 6 | - doesnotexist\(1\) 7 | -------------------------------------------------------------------------------- /tests/manconverterdata/input/links.1: -------------------------------------------------------------------------------- 1 | .TH LINKS 1 2 | .SH NAME 3 | links - contains links 4 | .SH DESCRIPTION 5 | 6 | Link to 7 | .BR foo (1) 8 | 9 | Link to 10 | .BR bar (3) 11 | 12 | Link to 13 | .BR xbaz (1x) 14 | 15 | Italic Link to 16 | \fIitalic\fP(2) 17 | 18 | Link to an unknown command 19 | .BR doesnotexist (1) 20 | -------------------------------------------------------------------------------- /testinst.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | cd $(dirname $0) 3 | SRC_DIR=$PWD 4 | VENV_DIR=/tmp/mupinst 5 | 6 | cd /tmp 7 | rm -rf $VENV_DIR 8 | echo "######## Creating env" 9 | virtualenv --python=python3 --system-site-packages $VENV_DIR 10 | 11 | echo "######## Activating env" 12 | . $VENV_DIR/bin/activate 13 | 14 | echo "######## Running install" 15 | cd $SRC_DIR 16 | ./setup.py install 17 | 18 | -------------------------------------------------------------------------------- /examples/man/fixworld.1: -------------------------------------------------------------------------------- 1 | .TH FIXWORLD 1 2 | .SH NAME 3 | fixworld \- fix what's wrong in the world 4 | .SH SYNOPSIS 5 | .B fixworld 6 | [\fB\-i\fR] 7 | [\fB\--interactive\fR] 8 | .SH DESCRIPTION 9 | .B fixworld 10 | fixes everything that is wrong in this world. 11 | .SH OPTIONS 12 | .TP 13 | .BR \-i ", " \-\-interactive\fR 14 | Ask for confirmation before fixing anything wrong. Can take a lot of time. 15 | -------------------------------------------------------------------------------- /tests/rstconvertertestcase.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mup.converters.rstconverter import RstConverter 4 | 5 | 6 | class RstConverterTestCase(TestCase): 7 | def test_doConvert(self): 8 | converter = RstConverter() 9 | 10 | output = converter._doConvert("hello") 11 | self.assertTrue(isinstance(output, str)) 12 | self.assertTrue("hello" in output) 13 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import unittest 4 | import sys 5 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) 7 | 8 | from converterstestcase import * 9 | from manconvertertestcase import * 10 | from rstconvertertestcase import * 11 | 12 | def main(): 13 | unittest.main() 14 | 15 | if __name__ == "__main__": 16 | main() 17 | # vi: ts=4 sw=4 et 18 | -------------------------------------------------------------------------------- /examples/ronn/fixworld.ronn: -------------------------------------------------------------------------------- 1 | fixworld(1) -- fix what's wrong in the world 2 | ============================================ 3 | 4 | ## SYNOPSIS 5 | 6 | `fixworld` [-i] [--interactive] 7 | 8 | ## DESCRIPTION 9 | 10 | **fixworld** fixes everything that is wrong in this world. 11 | 12 | ## OPTIONS 13 | 14 | * `-i`, `--interactive`: 15 | Ask for confirmation before fixing anything wrong. Can take a lot of time. 16 | -------------------------------------------------------------------------------- /mup/converters/htmlconverter.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .converter import Converter 4 | 5 | 6 | class HtmlConverter(Converter): 7 | """ 8 | A dumb converter which just passes content unaltered 9 | """ 10 | name = "Straight HTML" 11 | 12 | def _doConvert(self, src): 13 | return src 14 | 15 | def supports(self, filename): 16 | _, ext = os.path.splitext(filename) 17 | return ext.lower() in (".htm", ".html") 18 | -------------------------------------------------------------------------------- /mup/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import yaml 4 | 5 | from xdg import BaseDirectory 6 | 7 | 8 | def load(): 9 | dct = {} 10 | for filepath in BaseDirectory.load_config_paths("mup/mup.yaml"): 11 | with open(filepath) as f: 12 | try: 13 | dct.update(yaml.load(f)) 14 | except Exception as exc: 15 | logging.exception("Failed to load {}, skipping it.".format(filepath)) 16 | 17 | return dct 18 | -------------------------------------------------------------------------------- /mup/converters/rstconverter.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import docutils.core 4 | 5 | from .converter import Converter 6 | 7 | 8 | class RstConverter(Converter): 9 | name = "RST" 10 | reference = True 11 | 12 | def _doConvert(self, txt): 13 | out = docutils.core.publish_string(txt, writer_name="html") 14 | return out.decode("utf-8") 15 | 16 | def supports(self, filename): 17 | _, ext = os.path.splitext(filename) 18 | return ext.lower() == ".rst" 19 | -------------------------------------------------------------------------------- /mup/converters/markdownconverter.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | 3 | import markdown 4 | 5 | from .converter import Converter 6 | from .utils import applyTemplate 7 | 8 | 9 | class MarkdownConverter(Converter): 10 | name = "python-markdown" 11 | 12 | def _doConvert(self, txt): 13 | html = markdown.markdown(txt) 14 | return applyTemplate(html) 15 | 16 | def supports(self, filename): 17 | MATCHES = ["*.md", "*.mkd", "*.markdown", "README"] 18 | for match in MATCHES: 19 | if fnmatch.fnmatch(filename, match): 20 | return True 21 | return False 22 | -------------------------------------------------------------------------------- /examples/md/example.md: -------------------------------------------------------------------------------- 1 | # A top-level header 2 | 3 | Paragraphs are separated by a blank line. 4 | 5 | 2nd paragraph. *Italic*, **bold**, and `monospace`. 6 | 7 | Itemized lists look like: 8 | 9 | - this one 10 | - that one 11 | - the other one 12 | 13 | A link to [another page](another-page.md). 14 | 15 | ## A second-level header 16 | 17 | Here is a numbered list: 18 | 19 | 1. first item 20 | 2. second item 21 | 3. third item 22 | 23 | Some source code: 24 | 25 | #include 26 | 27 | int main(int argc, char** argv) 28 | { 29 | printf("Hello World\n"); 30 | return 0; 31 | } 32 | 33 | A quote: 34 | 35 | > With great power comes great responsibility. 36 | -------------------------------------------------------------------------------- /examples/rst/example.rst: -------------------------------------------------------------------------------- 1 | A top-level header 2 | ================== 3 | 4 | Paragraphs are separated by a blank line. 5 | 6 | 2nd paragraph. *Italic*, **bold**, and ``monospace``. 7 | 8 | Itemized lists look like: 9 | 10 | - this one 11 | - that one 12 | - the other one 13 | 14 | A second-level header 15 | --------------------- 16 | 17 | Here is a numbered list: 18 | 19 | 1. first item 20 | 2. second item 21 | 3. third item 22 | 23 | Some source code: 24 | 25 | :: 26 | 27 | #include 28 | 29 | int main(int argc, char** argv) 30 | { 31 | printf("Hello World\n"); 32 | return 0; 33 | } 34 | 35 | A quote: 36 | 37 | With great power comes great responsibility. 38 | -------------------------------------------------------------------------------- /examples/asciidoc/example.txt: -------------------------------------------------------------------------------- 1 | [[a-top-level-header]] 2 | A top-level header 3 | ------------------ 4 | 5 | Paragraphs are separated by a blank line. 6 | 7 | 2nd paragraph. _Italic_, *bold*, and `monospace`. 8 | 9 | Itemized lists look like: 10 | 11 | * this one 12 | * that one 13 | * the other one 14 | 15 | [[a-second-level-header]] 16 | A second-level header 17 | ~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | Here is a numbered list: 20 | 21 | 1. first item 22 | 2. second item 23 | 3. third item 24 | 25 | Some source code: 26 | 27 | ------------------------------- 28 | #include 29 | 30 | int main(int argc, char** argv) 31 | { 32 | printf("Hello World\n"); 33 | return 0; 34 | } 35 | ------------------------------- 36 | 37 | A quote: 38 | 39 | ____________________________________________ 40 | With great power comes great responsibility. 41 | ____________________________________________ 42 | -------------------------------------------------------------------------------- /mup/converters/converter.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import os 3 | 4 | from mup.converters.utils import readFile, skipHeader 5 | 6 | 7 | class Converter(object): 8 | name = 'Unnamed' 9 | # Set to True if this converter wraps the reference implementation for the 10 | # markup it supports 11 | reference = False 12 | # Set to True if this converter uses online tools 13 | online = False 14 | 15 | def supports(self, filepath): 16 | raise NotImplementedError 17 | 18 | def convert(self, filename): 19 | ext = os.path.splitext(filename)[1] 20 | if ext == '.gz': 21 | fl = gzip.open(filename, 'rb') 22 | else: 23 | fl = open(filename, 'rb') 24 | src = readFile(fl) 25 | 26 | src = skipHeader(src) 27 | return self._doConvert(src) 28 | 29 | def _doConvert(self, txt): 30 | raise NotImplementedError 31 | -------------------------------------------------------------------------------- /mup/converters/ghmarkdown.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import json 4 | import requests 5 | import sys 6 | 7 | 8 | def main(): 9 | parser = argparse.ArgumentParser() 10 | parser.add_argument('--mode', default='markdown', choices=['markdown', 'gfm']) 11 | args = parser.parse_args() 12 | 13 | payload = { 14 | 'mode': args.mode, 15 | 'text': sys.stdin.read() 16 | } 17 | headers = { 18 | 'content-type': 'application/json' 19 | } 20 | rs = requests.post('https://api.github.com/markdown', 21 | data=json.dumps(payload), headers=headers) 22 | 23 | text = rs.text 24 | if rs.status_code == 200: 25 | print(text) 26 | return 0 27 | else: 28 | print(text, file=sys.stderr) 29 | return rs.status_code 30 | 31 | 32 | if __name__ == '__main__': 33 | sys.exit(main()) 34 | # vi: ts=4 sw=4 et 35 | -------------------------------------------------------------------------------- /mup/mupman.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import subprocess 4 | import sys 5 | 6 | from mup.main import showMainWindow 7 | 8 | 9 | def main(): 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('-f', '--nofork', dest='foreground', 12 | action='store_true', help='Foreground: Do not fork at startup') 13 | parser.add_argument('section', nargs='?') 14 | parser.add_argument('page') 15 | 16 | args = parser.parse_args() 17 | 18 | cmd = ['man', '--where'] 19 | if args.section: 20 | cmd.append(args.section) 21 | cmd.append(args.page) 22 | try: 23 | path = subprocess.check_output(cmd).strip().decode('utf-8') 24 | except subprocess.CalledProcessError as exc: 25 | return 1 26 | 27 | return showMainWindow(path, foreground=args.foreground) 28 | 29 | 30 | if __name__ == '__main__': 31 | sys.exit(main()) 32 | # vi: ts=4 sw=4 et 33 | -------------------------------------------------------------------------------- /mup/data/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50 | 51 | 52 |
53 | %content% 54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | MUP: Markup previewer 4 | 5 | :copyright: 2012-2014 Aurélien Gâteau. 6 | :license: BSD. 7 | """ 8 | import os 9 | 10 | from setuptools import setup 11 | 12 | import mup 13 | 14 | DESCRIPTION = "Markup previewer" 15 | 16 | CONVERTERS_DIR = 'share/mup/converters' 17 | CONVERTERS = [os.path.join(CONVERTERS_DIR, x) for x in os.listdir(CONVERTERS_DIR) if x.endswith(".yaml")] 18 | 19 | setup(name=mup.__appname__, 20 | version=mup.__version__, 21 | description=DESCRIPTION, 22 | author="Aurélien Gâteau", 23 | author_email="mail@agateau.com", 24 | license=mup.__license__, 25 | platforms=["any"], 26 | url="http://github.com/agateau/mup", 27 | install_requires=["pyxdg"], 28 | packages=["mup", "mup.converters"], 29 | package_data={ 30 | "mup": ["data/*.html"], 31 | }, 32 | data_files=[ 33 | ('share/applications', ['share/applications/mup.desktop']), 34 | ('share/mup/converters', CONVERTERS), 35 | ], 36 | entry_points={ 37 | "console_scripts": [ 38 | "mup-gh-markdown = mup.converters.ghmarkdown:main", 39 | "mup-man = mup.converters.man:main", 40 | ], 41 | "gui_scripts": [ 42 | "mup = mup.main:main", 43 | "mupman = mup.mupman:main", 44 | ] 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /mup/converterthread.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from PyQt5.QtCore import * 4 | 5 | 6 | class ConverterThread(QThread): 7 | done = pyqtSignal(str) 8 | 9 | def __init__(self, parent=None): 10 | super(ConverterThread, self).__init__(parent) 11 | self._mutex = QMutex() 12 | self._converter = None 13 | self._filename = None 14 | 15 | def setConverter(self, converter): 16 | with QMutexLocker(self._mutex): 17 | if self._converter == converter: 18 | return 19 | self._converter = converter 20 | self.reload() 21 | 22 | def setFilename(self, filename): 23 | with QMutexLocker(self._mutex): 24 | if self._filename == filename: 25 | return 26 | self._filename = filename 27 | self.reload() 28 | 29 | def filename(self): 30 | with QMutexLocker(self._mutex): 31 | name = self._filename 32 | return name 33 | 34 | def reload(self): 35 | if self._filename and self._converter: 36 | self.start() 37 | 38 | def run(self): 39 | with QMutexLocker(self._mutex): 40 | filename = str(self._filename) 41 | if os.path.exists(filename) and self._converter is not None: 42 | html = self._converter.convert(self._filename) 43 | else: 44 | html = '' 45 | self.done.emit(html) 46 | -------------------------------------------------------------------------------- /tests/converterstestcase.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from unittest import TestCase 3 | 4 | from mup.converters.converter import Converter 5 | from mup.converters.utils import selectBestConverter, skipHeader, readFile 6 | 7 | class ConvertersTestCase(TestCase): 8 | def testSkipHeader(self): 9 | data = [ 10 | ("key:value\n\nHello", "Hello"), 11 | ("key1: value1\nkey2: value2\n\nBye", "Bye"), 12 | ("Plain text", "Plain text"), 13 | ] 14 | for src, expected in data: 15 | dst = skipHeader(src) 16 | self.assertEqual(dst, expected) 17 | 18 | def testReadFile(self): 19 | data = [ 20 | (b"\xef\xbb\xbfFoo", "Foo"), 21 | (b"Bar", "Bar"), 22 | ] 23 | 24 | for src, expected in data: 25 | fl = BytesIO(src) 26 | dst = readFile(fl) 27 | self.assertEqual(dst, expected) 28 | 29 | def testSelectBestConverter(self): 30 | def mkconverter(online=False, reference=False): 31 | converter = Converter() 32 | converter.online = online 33 | converter.reference = reference 34 | return converter 35 | 36 | online = mkconverter(online=True) 37 | reference = mkconverter(reference=True) 38 | normal = mkconverter() 39 | 40 | self.assertEqual(selectBestConverter([online, reference, normal]), reference) 41 | self.assertEqual(selectBestConverter([online, normal]), normal) 42 | -------------------------------------------------------------------------------- /mup/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import logging 4 | import os 5 | import signal 6 | import sys 7 | 8 | from PyQt5.QtCore import * 9 | from PyQt5.QtGui import * 10 | from PyQt5.QtWidgets import * 11 | 12 | from mup.window import Window 13 | 14 | 15 | def main(): 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument('-v', '--verbose', dest='verbose', 18 | action='store_true', help='Enable debug output. Implies --nofork') 19 | parser.add_argument('-f', '--nofork', dest='foreground', 20 | action='store_true', help='Foreground: Do not fork at startup') 21 | parser.add_argument('markup_file', nargs='?') 22 | args = parser.parse_args() 23 | 24 | loglevel = logging.DEBUG if args.verbose else logging.WARNING 25 | logging.basicConfig(format='%(levelname)s: %(message)s', 26 | level=loglevel) 27 | 28 | if args.verbose: 29 | args.foreground = True 30 | 31 | return showMainWindow(args.markup_file, foreground=args.foreground) 32 | 33 | 34 | def showMainWindow(path, foreground=False): 35 | if not foreground: 36 | # Close stdout and stderr to avoid polluting the terminal 37 | os.close(1) 38 | os.close(2) 39 | 40 | if os.fork() > 0: 41 | return 42 | 43 | signal.signal(signal.SIGINT, signal.SIG_DFL) 44 | app = QApplication(sys.argv) 45 | 46 | window = Window() 47 | if path: 48 | window.load(path) 49 | 50 | window.show() 51 | return app.exec_() 52 | 53 | 54 | if __name__ == "__main__": 55 | sys.exit(main()) 56 | # vi: ts=4 sw=4 et 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2014 Aurélien Gâteau and contributors. 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted (subject to the limitations in the 7 | disclaimer below) provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the 15 | distribution. 16 | 17 | * The name of the contributors may not be used to endorse or 18 | promote products derived from this software without specific prior 19 | written permission. 20 | 21 | NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE 22 | GRANTED BY THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT 23 | HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED 24 | WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 25 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 26 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 27 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 28 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 29 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 30 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 31 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 32 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN 33 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /mup/converters/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from xdg import BaseDirectory 5 | 6 | from .htmlconverter import HtmlConverter 7 | from .processconverter import ProcessConverter 8 | 9 | 10 | _converters = [] 11 | 12 | 13 | def _loadConvertersFromDir(configDir): 14 | for name in os.listdir(configDir): 15 | _, ext = os.path.splitext(name) 16 | if ext != ".yaml": 17 | continue 18 | fullPath = os.path.join(configDir, name) 19 | converter = ProcessConverter.fromConfigFile(fullPath) 20 | if converter is None: 21 | continue 22 | if converter.isAvailable(): 23 | yield converter 24 | else: 25 | logging.info('{} is not available'.format(converter.name)) 26 | 27 | 28 | def init(): 29 | global _converters 30 | 31 | for convertersDir in BaseDirectory.load_data_paths("mup/converters"): 32 | _converters.extend(_loadConvertersFromDir(convertersDir)) 33 | 34 | try: 35 | from .markdownconverter import MarkdownConverter 36 | _converters.append(MarkdownConverter()) 37 | except ImportError: 38 | logging.info('Failed to load internal Markdown converter, skipping.') 39 | 40 | try: 41 | from .rstconverter import RstConverter 42 | _converters.append(RstConverter()) 43 | except ImportError: 44 | logging.info('Failed to load internal rST converter, skipping.') 45 | 46 | _converters.append(HtmlConverter()) 47 | 48 | _converters.sort(key=lambda x: x.name) 49 | return _converters 50 | 51 | 52 | def findConverters(filepath): 53 | filename = os.path.basename(filepath) 54 | remaining, ext = os.path.splitext(filename) 55 | if ext == '.gz': 56 | filename = remaining 57 | return [x for x in _converters if x.supports(filename)] 58 | -------------------------------------------------------------------------------- /mup/converters/processconverter.py: -------------------------------------------------------------------------------- 1 | import distutils.spawn 2 | import fnmatch 3 | import logging 4 | import subprocess 5 | 6 | import yaml 7 | 8 | from .converter import Converter 9 | from .utils import applyTemplate 10 | 11 | 12 | class ProcessConverter(Converter): 13 | """ 14 | A converter which can use an external program to convert content 15 | """ 16 | @staticmethod 17 | def fromConfigFile(configFile): 18 | logging.info('Loading {}'.format(configFile)) 19 | with open(configFile) as fp: 20 | try: 21 | dct = yaml.load(fp) 22 | except Exception as exc: 23 | logging.exception('Failed to load {}.'.format(configFile)) 24 | return None 25 | 26 | obj = ProcessConverter() 27 | obj.name = dct['name'] 28 | obj.reference = dct.get('reference', False) 29 | obj.online = dct.get('online', False) 30 | obj._matches = dct['matches'] 31 | obj._cmd = dct['cmd'] 32 | obj._args = dct.get('args') 33 | obj._full = dct.get('full', False) 34 | return obj 35 | 36 | def isAvailable(self): 37 | return bool(distutils.spawn.find_executable(self._cmd)) 38 | 39 | def supports(self, filename): 40 | for match in self._matches: 41 | if fnmatch.fnmatch(filename, match): 42 | return True 43 | return False 44 | 45 | def _doConvert(self, src): 46 | cmd = self._cmd 47 | if self._args: 48 | cmd += ' ' + self._args 49 | popen = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, 50 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 51 | stdout, stderr = popen.communicate(src.encode('utf-8', errors='replace')) 52 | html = stdout.decode('utf-8') 53 | if not self._full: 54 | html = applyTemplate(html) 55 | if stderr: 56 | logging.error(stderr.decode('utf-8', errors='replace')) 57 | return html 58 | -------------------------------------------------------------------------------- /mup/converters/utils.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import re 3 | 4 | from pkg_resources import resource_string 5 | 6 | 7 | _ENC_BOMS = ( 8 | ('utf-8-sig', (codecs.BOM_UTF8,)), 9 | ('utf-16', (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)), 10 | ('utf-32', (codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)) 11 | ) 12 | 13 | 14 | _template = None 15 | 16 | 17 | def applyTemplate(html): 18 | global _template 19 | if _template is None: 20 | _template = resource_string("mup", "data/template.html").decode("utf-8") 21 | return _template.replace("%content%", html) 22 | 23 | 24 | def selectBestConverter(lst): 25 | """Select the best converter from lst. An offline converter is preferred to 26 | an online one. The reference implementation is preferred if it is 27 | available.""" 28 | 29 | def keyForConverter(converter): 30 | weight = 1 31 | if converter.online: 32 | weight += 1 33 | if converter.reference: 34 | weight -= 1 35 | return '{}{}'.format(weight, converter.name.lower()) 36 | 37 | lst = sorted(lst, key=keyForConverter) 38 | return lst[0] 39 | 40 | 41 | def _detectEncoding(head, default): 42 | for encoding, boms in _ENC_BOMS: 43 | if any(head.startswith(bom) for bom in boms): 44 | return encoding 45 | return default 46 | 47 | 48 | def readFile(fl): 49 | """ 50 | Read a file as unicode, correctly handling BOM 51 | """ 52 | try: 53 | raw = fl.read() 54 | encoding = _detectEncoding(raw, 'utf-8') 55 | return str(raw, encoding) 56 | finally: 57 | fl.close() 58 | 59 | 60 | def skipHeader(txt): 61 | """ 62 | Skip any yaml header, if present 63 | """ 64 | rx = re.compile("^[-a-zA-Z0-9_]+:") 65 | if not rx.match(txt): 66 | return txt 67 | 68 | src = txt.split("\n") 69 | for pos, line in enumerate(src): 70 | if line == "": 71 | # We passed the header 72 | return "\n".join(src[pos+1:]) 73 | return "" 74 | -------------------------------------------------------------------------------- /tests/manconvertertestcase.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from unittest import TestCase 5 | 6 | import yaml 7 | 8 | from mup.converters import man 9 | 10 | TEST_DIR = os.path.dirname(__file__) 11 | 12 | 13 | MAN_PAGE_DICT = { 14 | ('foo', '1'): '/man/1/foo.1', 15 | ('bar', '3'): '/man/3/bar.3', 16 | ('xbaz', '1x'): '/man/1/xbaz.1', 17 | ('italic', '2'): '/man/2/italic.2', 18 | } 19 | 20 | 21 | def fake_find_man_page(name, section): 22 | try: 23 | return MAN_PAGE_DICT[(name, section)] 24 | except KeyError: 25 | return None 26 | 27 | 28 | def run_test(test_case, data_dir): 29 | input_dir = os.path.join(data_dir, 'input') 30 | test_names = os.listdir(input_dir) 31 | for test_name in test_names: 32 | if test_name[0] == '.': 33 | continue 34 | input_path = os.path.join(input_dir, test_name) 35 | with open(input_path, 'rt') as fp: 36 | out = man.convert(fp, find_man_page_fcn=fake_find_man_page) 37 | 38 | expected_path = os.path.join(data_dir, 'expected', test_name + '.yaml') 39 | with open(expected_path, 'rt') as fp: 40 | dct = yaml.load(fp) 41 | patterns = dct['patterns'] 42 | 43 | for pattern in patterns: 44 | with test_case.subTest(test_name=test_name, pattern=pattern): 45 | if not re.search(pattern, out, re.MULTILINE): 46 | test_case.fail(out) 47 | 48 | 49 | class ManConverterTestCase(TestCase): 50 | def test_convert(self): 51 | data_dir = os.path.join(TEST_DIR, 'manconverterdata') 52 | run_test(self, data_dir) 53 | 54 | def test_find_man_page(self): 55 | TEST_DATA = [ 56 | ('ls', '1', 'man1/ls.1'), 57 | ('cron', '8', 'man8/cron.8'), 58 | ('doesnotexist', '3', None), 59 | ('ls', '1x', 'man1/ls.1'), # Does not really exist, but find_man_page should ignore the `x` suffix 60 | ] 61 | 62 | for name, section, path_excerpt in TEST_DATA: 63 | with self.subTest(name=name, section=section): 64 | path = man.find_man_page(name, section) 65 | if path_excerpt: 66 | self.assertEqual(type(path), str) 67 | self.assertTrue(path_excerpt in path) 68 | else: 69 | self.assertTrue(path is None) 70 | -------------------------------------------------------------------------------- /mup/converters/man.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import re 4 | import subprocess 5 | import sys 6 | 7 | 8 | CMD = ['groff', 9 | '-P', '-r', # No hr at the end of the page 10 | '-K', 'utf-8', 11 | '-mandoc', '-Thtml'] 12 | 13 | NAME_RE = r'([-_.a-zA-Z0-9]+)' 14 | SECTION_RE = r'\((\d+[px]?)\)' 15 | 16 | SECTION_LETTER_RX = re.compile(r'[a-z]+$') 17 | 18 | INSTALLED_LINK = '{}({})' 19 | 20 | NOT_INSTALLED_LINK = '{}({})' 21 | 22 | # Keys: (name, section) => path 23 | g_man_page_cache = {} 24 | def find_man_page(name, section): 25 | global g_man_page_cache 26 | try: 27 | return g_man_page_cache[(name, section)] 28 | except KeyError: 29 | pass 30 | 31 | num_section = SECTION_LETTER_RX.sub('', section) 32 | cmd = ['man', '--where', num_section] 33 | cmd.append(name) 34 | try: 35 | path = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).strip().decode('utf-8') 36 | except subprocess.CalledProcessError: 37 | path = None 38 | g_man_page_cache[(name, section)] = path 39 | return path 40 | 41 | 42 | def process_links(html, find_man_page_fcn): 43 | def repl(match): 44 | name = match.group(1) 45 | section = match.group(2) 46 | path = find_man_page_fcn(name, section) 47 | if path: 48 | return INSTALLED_LINK.format(path, name, section) 49 | else: 50 | return NOT_INSTALLED_LINK.format(name, section) 51 | return re.sub(r'<[bi]>' + NAME_RE + '' + SECTION_RE, 52 | repl, html) 53 | 54 | 55 | def convert(inputfp, find_man_page_fcn=find_man_page): 56 | popen = subprocess.Popen(CMD, stdin=inputfp, 57 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 58 | stdout, stderr = popen.communicate() 59 | stdout = stdout.decode('utf-8') 60 | 61 | # Turn '&minus' back into '-' so that options (-f, --quiet...) are easier to 62 | # search 63 | stdout = stdout.replace('−', '-') 64 | return process_links(stdout, find_man_page_fcn=find_man_page_fcn) 65 | 66 | 67 | def main(): 68 | parser = argparse.ArgumentParser() 69 | args = parser.parse_args() 70 | 71 | out = convert(sys.stdin) 72 | print(out) 73 | return 0 74 | 75 | 76 | if __name__ == '__main__': 77 | sys.exit(main()) 78 | # vi: ts=4 sw=4 et 79 | -------------------------------------------------------------------------------- /mup/history.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from PyQt5.QtCore import * 4 | from PyQt5.QtGui import * 5 | from PyQt5.QtWidgets import * 6 | 7 | 8 | class HistoryItem(object): 9 | def __init__(self, filename, converter, scrollPos=None): 10 | self.filename = os.path.abspath(str(filename)) 11 | self.converter = converter 12 | self.scrollPos = scrollPos 13 | 14 | def __str__(self): 15 | return os.path.basename(self.filename) 16 | 17 | 18 | class History(QObject): 19 | currentAboutToChange = pyqtSignal() 20 | currentChanged = pyqtSignal() 21 | 22 | def __init__(self, parent=None): 23 | QObject.__init__(self, parent) 24 | self._lst = [] 25 | self._index = -1 26 | 27 | self.backAction = QAction(self.tr("Back"), self) 28 | self.backAction.setIcon(QIcon.fromTheme("go-previous")) 29 | self.backAction.triggered.connect(self._goBack) 30 | self.forwardAction = QAction(self.tr("Forward"), self) 31 | self.forwardAction.triggered.connect(self._goForward) 32 | self.forwardAction.setIcon(QIcon.fromTheme("go-next")) 33 | self._updateBackForwardActions() 34 | 35 | self.currentChanged.connect(self._updateBackForwardActions) 36 | 37 | def push(self, historyItem): 38 | """ 39 | Discard items after _index and add our item 40 | """ 41 | self.currentAboutToChange.emit() 42 | self._index += 1 43 | self._lst = self._lst[:self._index] 44 | self._lst.append(historyItem) 45 | self.currentChanged.emit() 46 | 47 | def current(self): 48 | return self._lst[self._index] if self._index != -1 else None 49 | 50 | def _canGoBack(self): 51 | return self._index > 0 52 | 53 | def _canGoForward(self): 54 | return self._index < len(self._lst) - 1 55 | 56 | def _goBack(self): 57 | assert self._canGoBack() 58 | self._go(-1) 59 | 60 | def _goForward(self): 61 | assert self._canGoForward() 62 | self._go(1) 63 | 64 | def _go(self, delta): 65 | self.currentAboutToChange.emit() 66 | self._index += delta 67 | self.currentChanged.emit() 68 | 69 | def _updateBackForwardActions(self): 70 | if self._canGoBack(): 71 | self.backAction.setEnabled(True) 72 | item = self._lst[self._index - 1] 73 | tip = self.tr("Go back to %1").arg(str(item)) 74 | self.backAction.setToolTip(tip) 75 | else: 76 | self.backAction.setEnabled(False) 77 | 78 | if self._canGoForward(): 79 | self.forwardAction.setEnabled(True) 80 | item = self._lst[self._index + 1] 81 | tip = self.tr("Go to %1").arg(str(item)) 82 | self.forwardAction.setToolTip(tip) 83 | else: 84 | self.forwardAction.setEnabled(False) 85 | -------------------------------------------------------------------------------- /mup/findwidget.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from PyQt5.QtCore import * 3 | from PyQt5.QtGui import * 4 | from PyQt5.QtWidgets import * 5 | 6 | 7 | def _createArrowButton(arrowType, toolTip): 8 | button = QToolButton() 9 | button.setArrowType(arrowType) 10 | button.setAutoRaise(True) 11 | button.setToolTip(toolTip) 12 | return button 13 | 14 | 15 | class FindWidget(QWidget): 16 | closeRequested = pyqtSignal() 17 | 18 | def __init__(self, view, parent=None): 19 | QWidget.__init__(self, parent) 20 | self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) 21 | 22 | self._view = view 23 | 24 | layout = QHBoxLayout(self) 25 | layout.setContentsMargins(QMargins()) 26 | self._lineEdit = QLineEdit() 27 | 28 | self._previousButton = _createArrowButton(Qt.UpArrow, self.tr("Previous")) 29 | self._previousButton.clicked.connect(self.findPrevious) 30 | 31 | self._nextButton = _createArrowButton(Qt.DownArrow, self.tr("Next")) 32 | self._nextButton.clicked.connect(self.findNext) 33 | 34 | self._closeButton = QToolButton() 35 | self._closeButton.setAutoRaise(True) 36 | self._closeButton.setText("⨯") 37 | self._closeButton.setToolTip(self.tr("Close")) 38 | self._closeButton.clicked.connect(self.closeRequested) 39 | 40 | layout.addWidget(self._lineEdit) 41 | layout.addWidget(self._previousButton) 42 | layout.addWidget(self._nextButton) 43 | layout.addWidget(self._closeButton) 44 | 45 | self._findTimer = QTimer(self) 46 | self._findTimer.setSingleShot(True) 47 | self._findTimer.setInterval(100) 48 | self._findTimer.timeout.connect(self._doFind) 49 | 50 | self._lineEdit.textEdited.connect(self._findTimer.start) 51 | self._lineEdit.installEventFilter(self) 52 | 53 | self._notFoundPalette = QPalette(self._lineEdit.palette()) 54 | self._notFoundPalette.setColor(self._lineEdit.backgroundRole(), 55 | QColor(255, 102, 102)) 56 | 57 | def findNext(self): 58 | self._doFind() 59 | 60 | def findPrevious(self): 61 | self._view.find(self._lineEdit.text(), backward=True) 62 | 63 | def _doFind(self): 64 | text = self._lineEdit.text() 65 | found = self._view.find(text) 66 | if found or text.isEmpty(): 67 | self._lineEdit.setPalette(self.palette()) 68 | else: 69 | self._lineEdit.setPalette(self._notFoundPalette) 70 | 71 | def prepareNewSearch(self): 72 | self._lineEdit.clear() 73 | self._lineEdit.setFocus() 74 | 75 | def eventFilter(self, obj, event): 76 | if event.type() == QEvent.KeyPress: 77 | if event.key() == Qt.Key_Escape: 78 | self.closeRequested.emit() 79 | return False 80 | -------------------------------------------------------------------------------- /mup/view.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import * 2 | from PyQt5.QtGui import * 3 | from PyQt5.QtWebKit import * 4 | from PyQt5.QtWidgets import * 5 | from PyQt5.QtWebKitWidgets import * 6 | 7 | from . import converters 8 | 9 | from .converterthread import ConverterThread 10 | 11 | 12 | class View(QWidget): 13 | internalUrlClicked = pyqtSignal(QUrl) 14 | loadRequested = pyqtSignal(str) 15 | 16 | def __init__(self, parent=None): 17 | QWidget.__init__(self, parent) 18 | 19 | self._thread = ConverterThread() 20 | self._thread.done.connect(self._setHtml) 21 | 22 | self._setupView() 23 | self._setupLinkLabel() 24 | 25 | layout = QHBoxLayout(self) 26 | layout.setContentsMargins(QMargins()) 27 | layout.addWidget(self._view) 28 | 29 | self._lastScrollPos = None 30 | 31 | def _setupView(self): 32 | self._view = QWebView(self) 33 | page = QWebPage() 34 | page.setLinkDelegationPolicy(QWebPage.DelegateAllLinks) 35 | page.linkClicked.connect(self._openUrl) 36 | page.linkHovered.connect(self._showHoveredLink) 37 | self._view.setPage(page) 38 | self._view.loadFinished.connect(self._onLoadFinished) 39 | 40 | def _setupLinkLabel(self): 41 | self._linkLabel = QLabel(self._view) 42 | self._linkLabel.setStyleSheet(""" 43 | background-color: #abc; 44 | color: #123; 45 | padding: 3px; 46 | border-bottom-right-radius: 3px; 47 | border-right: 1px solid #bce; 48 | border-bottom: 1px solid #bce; 49 | """) 50 | self._linkLabel.hide() 51 | self._linkLabelHideTimer = QTimer(self) 52 | self._linkLabelHideTimer.setSingleShot(True) 53 | self._linkLabelHideTimer.setInterval(250) 54 | self._linkLabelHideTimer.timeout.connect(self._linkLabel.hide) 55 | 56 | def load(self, filename, converter, lastScrollPos=None): 57 | self._lastScrollPos = lastScrollPos 58 | self._thread.setFilename(filename) 59 | self._thread.setConverter(converter) 60 | 61 | def reload(self): 62 | self._lastScrollPos = self.scrollPosition() 63 | self._thread.reload() 64 | 65 | def find(self, text, backward=False): 66 | options = QWebPage.FindWrapsAroundDocument 67 | if backward: 68 | options |= QWebPage.FindBackward 69 | found = self._view.findText(text, options) 70 | 71 | # Redo highlight 72 | options = QWebPage.HighlightAllOccurrences 73 | self._view.findText("", options) 74 | self._view.findText(text, options) 75 | return found 76 | 77 | def removeFindHighlights(self): 78 | options = QWebPage.HighlightAllOccurrences 79 | self._view.findText("", options) 80 | 81 | def scrollPosition(self): 82 | return self._view.page().currentFrame().scrollPosition() 83 | 84 | def _setHtml(self, html): 85 | filename = str(self._thread.filename()) 86 | baseUrl = QUrl.fromLocalFile(filename) 87 | self._view.setHtml(html, baseUrl) 88 | 89 | def _onLoadFinished(self): 90 | if self._lastScrollPos is not None: 91 | frame = self._view.page().currentFrame() 92 | frame.setScrollPosition(self._lastScrollPos) 93 | self._lastScrollPos = None 94 | 95 | def _openUrl(self, url): 96 | if url.scheme() == "internal": 97 | self.internalUrlClicked.emit(url) 98 | return 99 | 100 | if url.scheme() in ("file", ""): 101 | frame = self._view.page().currentFrame() 102 | if url.path() == frame.baseUrl().path(): 103 | anchor = url.fragment() 104 | frame.scrollToAnchor(anchor) 105 | return 106 | elif converters.findConverters(str(url.path())): 107 | self.loadRequested.emit(url.path()) 108 | return 109 | 110 | QDesktopServices.openUrl(url) 111 | 112 | def _showHoveredLink(self, link, title, textContent): 113 | if link.isEmpty(): 114 | self._linkLabelHideTimer.start() 115 | return 116 | 117 | self._linkLabelHideTimer.stop() 118 | text = link 119 | text.replace("file:///", "/") 120 | self._linkLabel.setText(text) 121 | self._linkLabel.adjustSize() 122 | 123 | self._linkLabel.show() 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MUP: a Markup Previewer 2 | 3 | MUP is a markup previewer. It supports multiple markup formats. You can use it 4 | to read markup text, but it is also useful when writing markup text to check 5 | how your work looks, thanks to its refresh-as-you-save feature. 6 | 7 | ![MUP in action](screenshot.png) 8 | 9 | ## Features 10 | 11 | - Supports multiple markup formats, easy to extend 12 | - Automatically refreshes itself when the document is modified, tries to retain 13 | the position in the document after refreshing 14 | - Skips metadata headers, such as those used by static blog generators like 15 | Jekyll 16 | - Supports gzipped documents, useful to read documentation shipped with Debian 17 | packages 18 | - Comes with a wrapper for man pages 19 | 20 | ## Supported Formats 21 | 22 | MUP supports Markdown and reStructuredText using Python modules. 23 | 24 | It also supports the following formats using external converters: 25 | 26 | - Markdown 27 | - GitHub Flavored Markdown 28 | - Ronn 29 | - Man pages 30 | - Asciidoc 31 | - Mediawiki 32 | 33 | External converters are command line tools which are invoked by MUP to convert 34 | input files. To be used as an external converter, the tool must accept markup 35 | on stdin and produce HTML on stdout. 36 | 37 | ## Usage 38 | 39 | Start MUP like this: 40 | 41 | mup markup_file 42 | 43 | To read a man page with mup: 44 | 45 | mupman ls 46 | 47 | Or: 48 | 49 | mupman 5 crontab 50 | 51 | ## Requirements 52 | 53 | MUP requires Python 3 and the following Python modules: 54 | 55 | - [PyQt5][], including PyQt5 WebKit, which can be in a separate package 56 | - [PyYAML][] 57 | - [PyXDG][] 58 | 59 | It can make use of other Python modules and external tools to render various 60 | markup formats. 61 | 62 | [PyQt5]: https://www.riverbankcomputing.com/software/pyqt/download5 63 | [PyYAML]: http://pyyaml.org/wiki/PyYAML 64 | [PyXDG]: https://freedesktop.org/wiki/Software/pyxdg/ 65 | 66 | ### Markdown 67 | 68 | For Markdown you need to install one of these: 69 | 70 | - Python [Markdown][python-markdown] module 71 | - [Pandoc][] 72 | - [CommonMark][] 73 | - [Gruber Markdown][Gruber] 74 | - Python [Requests][requests] module: to render Markdown using GitHub Rest API 75 | (slow but accurate) 76 | 77 | ### GitHub Flavored Markdown (GFM) 78 | 79 | For GitHub Flavored Markdown (Markdown which takes newlines into account) you 80 | need to install one of these: 81 | 82 | - [kramdown][] 83 | - Python [Requests][requests] module: to render GFM using GitHub Rest API (slow 84 | but accurate) 85 | 86 | ### reStructuredText 87 | 88 | For reStructuredText you need to install the [docutils][] Python module. 89 | 90 | ### Man pages 91 | 92 | For man pages you need to install [Groff][] (but it is already installed on 93 | most Linux distributions). 94 | 95 | ### Ronn 96 | 97 | For Ronn you need to install [Ronn][]. 98 | 99 | ### Asciidoc 100 | 101 | For Asciidoc you need to install [Asciidoc][]. 102 | 103 | ### Mediawiki 104 | 105 | For Mediawiki you need to install [Pandoc][]. 106 | 107 | [python-markdown]: https://pythonhosted.org/Markdown/ 108 | [Pandoc]: http://pandoc.org 109 | [kramdown]: http://kramdown.gettalong.org/ 110 | [CommonMark]: http://commonmark.org 111 | [Gruber]: http://daringfireball.net/projects/markdown/ 112 | 113 | [docutils]: http://docutils.sourceforge.net/ 114 | 115 | [Groff]: https://www.gnu.org/software/groff/ 116 | 117 | [Ronn]: http://rtomayko.github.io/ronn/ 118 | [Asciidoc]: http://www.methods.co.nz/asciidoc/ 119 | [Requests]: http://python-requests.org 120 | 121 | ## Installation 122 | 123 | Run `./setup.py install` as root. 124 | 125 | ## Editing files 126 | 127 | You can edit the current file by clicking on the menu button then select "Open 128 | with Editor". This will open the file in the configured editor. 129 | 130 | To configure which editor should be used, edit `~/.config/mup/mup.yaml` and add 131 | the following content: 132 | 133 | editor: name-of-your-editor 134 | 135 | Note: for now you cannot define arguments for the editor. If you need arguments 136 | you will have to write a wrapper shell script. 137 | 138 | ## Defining a new Converter 139 | 140 | To declare the `foo2html` command as a converter for .foo or .foobar files, 141 | create a `foo.yaml` file in `/usr/share/mup/converters` or in 142 | `~/.local/share/mup/converters` with the following content: 143 | 144 | name: Foo 145 | cmd: foo2html 146 | matches: ["*.foo", "*.foobar"] 147 | 148 | If MUP can find the `foo2html` binary, it will use it whenever it tries to open 149 | a .foo file. 150 | 151 | Other optional keys: 152 | 153 | - `args`: Arguments to pass to the command 154 | - `full`: Set to true if the command creates a complete HTML document, not just 155 | an HTML snippet (defaults to false) 156 | - `online`: Set to true if the converter uses an online service. Those are 157 | slower than offline ones and are thus not selected by default 158 | - `reference`: Set to true if the converter is the reference implementation for 159 | the format it handles. A reference converter will be selected by default if 160 | available 161 | 162 | ## Contributing 163 | 164 | MUP is managed using the [lightweight project management policy][lpmp]. 165 | 166 | Get the code from `https://github.com/agateau/mup` then file pull requests 167 | against the `dev` branch. 168 | 169 | [lpmp]: http://agateau.com/2014/lightweight-project-management 170 | 171 | ## Author 172 | 173 | Aurélien Gâteau 174 | 175 | ## License 176 | 177 | BSD 178 | -------------------------------------------------------------------------------- /mup/window.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import logging 3 | import os 4 | import subprocess 5 | 6 | from pkg_resources import resource_filename 7 | 8 | from PyQt5.QtCore import * 9 | from PyQt5.QtGui import * 10 | from PyQt5.QtWidgets import * 11 | 12 | from . import config 13 | from .view import View 14 | from .findwidget import FindWidget 15 | 16 | from . import converters 17 | from .converters.utils import selectBestConverter 18 | 19 | from .history import History, HistoryItem 20 | 21 | 22 | class Window(QMainWindow): 23 | def __init__(self): 24 | QMainWindow.__init__(self) 25 | 26 | self.config = config.load() 27 | self.converterList = [] 28 | converters.init() 29 | 30 | self.watcher = QFileSystemWatcher(self) 31 | self.watcher.fileChanged.connect(self._onFileChanged) 32 | 33 | self.setupHistory() 34 | 35 | self.setupView() 36 | self.setupToolBar() 37 | self.setWindowIcon(QIcon.fromTheme("text-plain")) 38 | 39 | def closeEvent(self, event): 40 | QMainWindow.closeEvent(self, event) 41 | 42 | def setupHistory(self): 43 | self._history = History() 44 | self._history.currentAboutToChange.connect(self._updateCurrentHistoryItemScrollPos) 45 | self._history.currentAboutToChange.connect(self._stopWatching) 46 | self._history.currentChanged.connect(self._loadCurrentHistoryItem) 47 | 48 | def setupToolBar(self): 49 | toolBar = self.addToolBar(self.tr("Main")) 50 | toolBar.setMovable(False) 51 | toolBar.setFloatable(False) 52 | toolBar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) 53 | 54 | action = toolBar.addAction(self.tr("Open")) 55 | action.setIcon(QIcon.fromTheme("document-open")) 56 | action.setShortcut(QKeySequence.Open) 57 | action.triggered.connect(self.openFileDialog) 58 | 59 | toolBar.addAction(self._history.backAction) 60 | toolBar.addAction(self._history.forwardAction) 61 | 62 | self.converterComboBox = QComboBox() 63 | self.converterComboBox.setSizeAdjustPolicy(QComboBox.AdjustToContents) 64 | label = QLabel(self.tr("&Converter:")) 65 | label.setBuddy(self.converterComboBox) 66 | 67 | widget = QWidget() 68 | layout = QHBoxLayout(widget) 69 | layout.addStretch() 70 | layout.addWidget(label) 71 | layout.addWidget(self.converterComboBox) 72 | toolBar.addWidget(widget) 73 | self.converterComboBox.currentIndexChanged.connect(self._onConverterChanged) 74 | self.converterComboBox.setFocusPolicy(Qt.ClickFocus) 75 | 76 | action = toolBar.addAction(self.tr("Menu")) 77 | action.setIcon(QIcon.fromTheme("applications-system")) 78 | action.setToolTip(self.tr("Menu (F10)")) 79 | action.setPriority(QAction.LowPriority) 80 | self.setupMenu(action) 81 | button = toolBar.widgetForAction(action) 82 | button.setPopupMode(QToolButton.InstantPopup) 83 | shortcut = QShortcut(Qt.Key_F10, self) 84 | shortcut.activated.connect(button.animateClick) 85 | 86 | def setupMenu(self, menuAction): 87 | menu = QMenu() 88 | menuAction.setMenu(menu) 89 | 90 | action = menu.addAction(self.tr("Force Reload")) 91 | action.setIcon(QIcon.fromTheme("view-refresh")) 92 | action.setShortcut(QKeySequence.Refresh) 93 | action.triggered.connect(self.reload) 94 | 95 | action = menu.addAction(self.tr("Open with Editor")) 96 | action.setIcon(QIcon.fromTheme("document-edit")) 97 | action.setShortcut(Qt.CTRL + Qt.Key_E) 98 | action.triggered.connect(self.edit) 99 | 100 | menu.addSeparator() 101 | 102 | action = menu.addAction(self.tr("Find")) 103 | action.setShortcuts((Qt.CTRL + Qt.Key_F, Qt.Key_Slash)) 104 | action.setIcon(QIcon.fromTheme("edit-find")) 105 | action.triggered.connect(self.toggleFindWidget) 106 | 107 | action = menu.addAction(self.tr("Find Next")) 108 | action.setShortcut(Qt.Key_F3) 109 | action.triggered.connect(self._findWidget.findNext) 110 | 111 | action = menu.addAction(self.tr("Find Previous")) 112 | action.setShortcut(Qt.SHIFT + Qt.Key_F3) 113 | action.triggered.connect(self._findWidget.findPrevious) 114 | 115 | menu.addSeparator() 116 | 117 | action = menu.addAction(self.tr("About MUP")) 118 | action.triggered.connect(self.showAboutDialog) 119 | 120 | def showAboutDialog(self): 121 | title = self.tr("About MUP") 122 | text = self.tr("

MUP, a Markup Previewer

" 123 | "

Aurélien Gâteau – mail@agateau.com

" 124 | "

http://github.com/agateau/mup

") 125 | QMessageBox.about(self, title, text) 126 | 127 | def setupView(self): 128 | central = QWidget() 129 | vboxLayout = QVBoxLayout(central) 130 | vboxLayout.setContentsMargins(QMargins()) 131 | vboxLayout.setSpacing(0) 132 | 133 | self.view = View() 134 | self.view.loadRequested.connect(self.load) 135 | self.view.internalUrlClicked.connect(self.handleInternalUrl) 136 | 137 | self._findWidget = FindWidget(self.view) 138 | self._findWidget.closeRequested.connect(self.toggleFindWidget) 139 | self._findWidget.hide() 140 | 141 | vboxLayout.addWidget(self.view) 142 | vboxLayout.addWidget(self._findWidget) 143 | 144 | self.setCentralWidget(central) 145 | 146 | def _updateCurrentHistoryItemScrollPos(self): 147 | item = self._history.current() 148 | if item: 149 | item.scrollPos = self.view.scrollPosition() 150 | 151 | def load(self, filename): 152 | self._history.push(HistoryItem(filename, None)) 153 | 154 | def _stopWatching(self): 155 | item = self._history.current() 156 | if item: 157 | self.watcher.removePath(item.filename) 158 | 159 | def _loadCurrentHistoryItem(self): 160 | item = self._history.current() 161 | 162 | # Update watcher 163 | self.watcher.addPath(item.filename) 164 | 165 | # Update title 166 | self.setWindowTitle(item.filename + " - MUP") 167 | 168 | # Find file to really show and update converter list 169 | if os.path.exists(item.filename): 170 | viewFilename = item.filename 171 | else: 172 | viewFilename = resource_filename(__name__, "data/placeholder.html") 173 | self.converterList = converters.findConverters(viewFilename) 174 | if not self.converterList: 175 | viewFilename = resource_filename(__name__, "data/unsupported.html") 176 | self.converterList = converters.findConverters(viewFilename) 177 | assert self.converterList 178 | converter = item.converter or selectBestConverter(self.converterList) 179 | self.updateConverterComboBox(currentConverter=converter) 180 | 181 | # Update view 182 | self.view.load(viewFilename, converter, item.scrollPos) 183 | 184 | def updateConverterComboBox(self, currentConverter=None): 185 | self.converterComboBox.blockSignals(True) 186 | self.converterComboBox.clear() 187 | for idx, converter in enumerate(self.converterList): 188 | self.converterComboBox.addItem(converter.name) 189 | if converter == currentConverter: 190 | self.converterComboBox.setCurrentIndex(idx) 191 | self.converterComboBox.blockSignals(False) 192 | 193 | def _onConverterChanged(self, index): 194 | if index == -1: 195 | return 196 | self._history.current().converter = self.converterList[index] 197 | self._updateCurrentHistoryItemScrollPos() 198 | self._loadCurrentHistoryItem() 199 | 200 | def _onFileChanged(self): 201 | item = self._history.current() 202 | if os.path.exists(item.filename): 203 | self.watcher.addPath(item.filename) 204 | self.reload() 205 | else: 206 | QTimer.singleShot(500, self._onFileChanged) 207 | 208 | def reload(self): 209 | self._updateCurrentHistoryItemScrollPos() 210 | self.view.reload() 211 | 212 | def edit(self): 213 | item = self._history.current() 214 | if not item: 215 | return 216 | editor = self.config.get("editor", "gvim") 217 | subprocess.call([editor, item.filename]) 218 | 219 | def toggleFindWidget(self): 220 | visible = not self._findWidget.isVisible() 221 | self._findWidget.setVisible(visible) 222 | if visible: 223 | self._findWidget.prepareNewSearch() 224 | else: 225 | self.view.removeFindHighlights() 226 | 227 | def handleInternalUrl(self, url): 228 | if url.path() == "create": 229 | self.edit() 230 | else: 231 | logging.error("Don't know how to handle internal url {}".format(url.toString())) 232 | 233 | def openFileDialog(self): 234 | name = QFileDialog.getOpenFileName(self, self.tr("Select a file to view"))[0] 235 | if not name: 236 | return 237 | self.load(name) 238 | --------------------------------------------------------------------------------