├── 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 + '[bi]>' + 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 | 
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 |
--------------------------------------------------------------------------------