diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..246e336 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,53 @@ +name: Code Quality + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + format-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install + + - name: Check code formatting + run: poetry run ruff format --check . + + - name: Run ruff linter + run: poetry run ruff check . + + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install + + - name: Run pre-commit checks + uses: pre-commit/action@v3.0.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd7a9d6..60b89d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,3 +5,10 @@ repos: - id: check-ast - id: check-case-conflict - id: check-yaml + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.5 + hooks: + - id: ruff-format + - id: ruff + args: [--fix] diff --git a/.vscode/typings/__builtins__.pyi b/.vscode/typings/__builtins__.pyi index 88febec..48cbde4 100644 --- a/.vscode/typings/__builtins__.pyi +++ b/.vscode/typings/__builtins__.pyi @@ -1,6 +1,2 @@ -def _(msg: str) -> str: - ... - - -def pgettext(context: str, message: str) -> str: - ... +def _(msg: str) -> str: ... +def pgettext(context: str, message: str) -> str: ... diff --git a/addon/globalPlugins/quickNotetaker/__init__.py b/addon/globalPlugins/quickNotetaker/__init__.py index 20cd575..ab9d762 100644 --- a/addon/globalPlugins/quickNotetaker/__init__.py +++ b/addon/globalPlugins/quickNotetaker/__init__.py @@ -5,7 +5,6 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from logHandler import log import globalPluginHandler from scriptHandler import script import gui @@ -24,61 +23,59 @@ class GlobalPlugin(globalPluginHandler.GlobalPlugin): + def __init__(self, *args, **kwargs): + super(GlobalPlugin, self).__init__(*args, **kwargs) + addonConfig.initialize() + notesManager.initialize() + try: + os.mkdir(addonConfig.getValue("notesDocumentsPath")) + except FileNotFoundError: + # The user has no documents directory + # Create the add-on documents folder in the user root folder instead + addonConfig.setValue("notesDocumentsPath", os.path.expanduser("~\\QuickNotetaker")) + os.mkdir(addonConfig.getValue("notesDocumentsPath")) + except FileExistsError: + pass + try: + os.mkdir(TEMP_FILES_PATH) + except FileExistsError: + pass + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(QuickNotetakerPanel) - def __init__(self, *args, **kwargs): - super(GlobalPlugin, self).__init__(*args, **kwargs) - addonConfig.initialize() - notesManager.initialize() - try: - os.mkdir(addonConfig.getValue("notesDocumentsPath")) - except FileNotFoundError: - # The user has no documents directory - # Create the add-on documents folder in the user root folder instead - addonConfig.setValue("notesDocumentsPath", os.path.expanduser("~\\QuickNotetaker")) - os.mkdir(addonConfig.getValue("notesDocumentsPath")) - except FileExistsError: - pass - try: - os.mkdir(TEMP_FILES_PATH) - except FileExistsError: - pass - gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append( - QuickNotetakerPanel) + def terminate(self): + super(GlobalPlugin, self).terminate() + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove(QuickNotetakerPanel) + if not os.path.isdir(TEMP_FILES_PATH): + return + for file in os.listdir(TEMP_FILES_PATH): + os.remove(os.path.join(TEMP_FILES_PATH, file)) - def terminate(self): - super(GlobalPlugin, self).terminate() - gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove( - QuickNotetakerPanel) - if not os.path.isdir(TEMP_FILES_PATH): - return - for file in os.listdir(TEMP_FILES_PATH): - os.remove(os.path.join(TEMP_FILES_PATH, file)) + # Translators: the name of the add-on category in input gestures + scriptCategory = _("Quick Notetaker") - # Translators: the name of the add-on category in input gestures - scriptCategory=_("Quick Notetaker") + @script( + # Translators: the description for the command to open the notetaker dialog + description=_("Shows the Notetaker interface for writing a new note"), + gesture="kb:NVDA+alt+n", + ) + def script_showNoteTakerUI(self, gesture): + noteTitle = None + if addonConfig.getValue("captureActiveWindowTitle"): + noteTitle = api.getForegroundObject().name + gui.mainFrame.prePopup() + dialogs.noteTakerInstance = NoteTakerDialog(noteTitle=noteTitle) + dialogs.noteTakerInstance.Show() + gui.mainFrame.postPopup() - @ script( - # Translators: the description for the command to open the notetaker dialog - description=_("Shows the Notetaker interface for writing a new note"), - gesture="kb:NVDA+alt+n" - ) - def script_showNoteTakerUI(self, gesture): - noteTitle=None - if addonConfig.getValue("captureActiveWindowTitle"): - noteTitle=api.getForegroundObject().name - gui.mainFrame.prePopup() - dialogs.noteTakerInstance=NoteTakerDialog(noteTitle=noteTitle) - dialogs.noteTakerInstance.Show() - gui.mainFrame.postPopup() - - @ script( - description=_( - # Translators: the description for the command to open the Notes Manager - "Shows the Notes Manager interface for viewing and managing notes"), - gesture="kb:NVDA+alt+v" - ) - def script_showNotesManagerDialogUI(self, gesture): - gui.mainFrame.prePopup() - dialogs.notesManagerInstance=NotesManagerDialog() - dialogs.notesManagerInstance.Show() - gui.mainFrame.postPopup() + @script( + description=_( + # Translators: the description for the command to open the Notes Manager + "Shows the Notes Manager interface for viewing and managing notes" + ), + gesture="kb:NVDA+alt+v", + ) + def script_showNotesManagerDialogUI(self, gesture): + gui.mainFrame.prePopup() + dialogs.notesManagerInstance = NotesManagerDialog() + dialogs.notesManagerInstance.Show() + gui.mainFrame.postPopup() diff --git a/addon/globalPlugins/quickNotetaker/addonConfig.py b/addon/globalPlugins/quickNotetaker/addonConfig.py index 4fd94d9..81c5399 100644 --- a/addon/globalPlugins/quickNotetaker/addonConfig.py +++ b/addon/globalPlugins/quickNotetaker/addonConfig.py @@ -11,30 +11,30 @@ def initialize(): - configSpec = { - "notesDocumentsPath": f"string(default={os.path.normpath(os.path.expanduser('~/documents/quickNotetaker'))})", - "askWhereToSaveDocx": "boolean(default=False)", - "openFileAfterCreation": "boolean(default=False)", - "captureActiveWindowTitle": "boolean(default=True)", - "rememberTakerSizeAndPos": "boolean(default=False)", - "autoAlignText": "boolean(default=true)", - "takerXPos": f"integer(default={wx.DefaultPosition.x})", - "takerYPos": f"integer(default={wx.DefaultPosition.y})", - "takerWidth": "integer(default=500)", - "takerHeight": "integer(default=500)", - } - config.conf.spec["quickNotetaker"] = configSpec + configSpec = { + "notesDocumentsPath": f"string(default={os.path.normpath(os.path.expanduser('~/documents/quickNotetaker'))})", + "askWhereToSaveDocx": "boolean(default=False)", + "openFileAfterCreation": "boolean(default=False)", + "captureActiveWindowTitle": "boolean(default=True)", + "rememberTakerSizeAndPos": "boolean(default=False)", + "autoAlignText": "boolean(default=true)", + "takerXPos": f"integer(default={wx.DefaultPosition.x})", + "takerYPos": f"integer(default={wx.DefaultPosition.y})", + "takerWidth": "integer(default=500)", + "takerHeight": "integer(default=500)", + } + config.conf.spec["quickNotetaker"] = configSpec def getValue(key): - try: - return config.conf["quickNotetaker"][key] - except KeyError: - # Config key doesn't exist, likely upgrading from older version - # Re-initialize config to ensure all defaults are set - initialize() - return config.conf["quickNotetaker"][key] + try: + return config.conf["quickNotetaker"][key] + except KeyError: + # Config key doesn't exist, likely upgrading from older version + # Re-initialize config to ensure all defaults are set + initialize() + return config.conf["quickNotetaker"][key] def setValue(key, value): - config.conf["quickNotetaker"][key] = value + config.conf["quickNotetaker"][key] = value diff --git a/addon/globalPlugins/quickNotetaker/constants.py b/addon/globalPlugins/quickNotetaker/constants.py index 858e353..e7eb9be 100644 --- a/addon/globalPlugins/quickNotetaker/constants.py +++ b/addon/globalPlugins/quickNotetaker/constants.py @@ -7,7 +7,6 @@ import globalVars import os -import sys import shutil import config diff --git a/addon/globalPlugins/quickNotetaker/dialogs.py b/addon/globalPlugins/quickNotetaker/dialogs.py index 9be108c..69ff316 100644 --- a/addon/globalPlugins/quickNotetaker/dialogs.py +++ b/addon/globalPlugins/quickNotetaker/dialogs.py @@ -9,16 +9,23 @@ import gui from gui import guiHelper from gui import nvdaControls -from gui.dpiScalingHelper import DpiScalingHelperMixin, DpiScalingHelperMixinWithoutInit +from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit from gui.settingsDialogs import NVDASettingsDialog from logHandler import log import ui from .lib.markdown2 import markdown import weakref import api -import re from . import notesManager -from .helpers import * +from .helpers import ( + handleMdContent, + getTitle, + handleTextAlignment, + Align, + saveAsWord, + getPreviewText, + openInWord, +) from . import addonConfig from .settingsPanel import QuickNotetakerPanel import addonHandler @@ -36,636 +43,612 @@ class NoteTakerDialog(wx.Dialog): - - @classmethod - def _instance(cls): - """ type: () -> NoteTakerDialog - return None until this is replaced with a weakref.ref object. Then the instance is retrieved - with by treating that object as a callable. - """ - return None - - def __new__(cls, *args, **kwargs): - instance = NoteTakerDialog._instance() - if instance is None: - return super(NoteTakerDialog, cls).__new__(cls, *args, **kwargs) - return instance - - def _getDialogSizeAndPosition(self): - dialogSize = wx.Size(500, 500) - dialogPos = wx.DefaultPosition - if addonConfig.getValue("rememberTakerSizeAndPos"): - log.debug( - "Setting Quick Notetaker Notetaker window position and size") - dialogSize = wx.Size( - addonConfig.getValue("takerWidth"), - addonConfig.getValue("takerHeight") - ) - dialogPos = wx.Point( - x=addonConfig.getValue("takerXPos"), - y=addonConfig.getValue("takerYPos") - ) - return dialogSize, dialogPos - - def __init__(self, currentNote=None, noteTitle=None): - if NoteTakerDialog._instance() is not None: - return - NoteTakerDialog._instance = weakref.ref(self) - - dialogSize, dialogPos = self._getDialogSizeAndPosition() - # Translators: the title for the Quick Notetaker Notetaker window - title = _("Notetaker - Quick Notetaker") - if noteTitle: - title = f"{noteTitle} - {title}" - - super().__init__( - gui.mainFrame, - title=title, - size=dialogSize, - pos=dialogPos, - style=wx.CAPTION | wx.CLOSE_BOX | wx.RESIZE_BORDER | wx.STAY_ON_TOP - ) - - mainSizer = wx.BoxSizer(wx.VERTICAL) - sHelper = guiHelper.BoxSizerHelper(self, wx.VERTICAL) - # Translators: a lable of a button in Notetaker dialog - openManagerButton = wx.Button(self, label=_("Open Notes &Manager...")) - openManagerButton.Bind(wx.EVT_BUTTON, self.onOpenManager) - sHelper.addItem(openManagerButton, flag=wx.ALIGN_CENTER) - if notesManagerInstance: - openManagerButton.Disable() - buttonsHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) - preViewButton = buttonsHelper.addButton( - self, - # Translators: a lable of a button in Notetaker dialog - label=_("P&review note...")) - preViewButton.Bind(wx.EVT_BUTTON, self.onPreview) - # Translators: a lable of a button in Notetaker dialog - copyButton = buttonsHelper.addButton(self, label=_("Co&py")) - copyButton.Bind(wx.EVT_BUTTON, self.onCopy) - copyHtmlButton = buttonsHelper.addButton( - self, - # Translators: a lable of a button in Notetaker dialog - label=_("Copy &HTML code")) - copyHtmlButton.Bind(wx.EVT_BUTTON, self.onCopyAsHtml) - sHelper.addItem(buttonsHelper.sizer) - - sizer = wx.BoxSizer(wx.VERTICAL) - # Translators: The lable of the note content edit area in Notetaker dialog - label = wx.StaticText(self, label=_("&Note content:")) - sizer.Add(label, flag=wx.ALIGN_CENTER_HORIZONTAL) - sizer.AddSpacer(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL) - self.noteEditArea = wx.TextCtrl( - self, style=wx.TE_RICH2 | wx.TE_MULTILINE) - self.noteEditArea.Bind(wx.EVT_TEXT, self.onCharacter) - self.noteEditArea.Bind(wx.EVT_KEY_UP, self.onKeyUp) - sizer.Add(self.noteEditArea, proportion=1, flag=wx.EXPAND) - sHelper.addItem(sizer, proportion=1, flag=wx.EXPAND) - if noteTitle: - self.noteEditArea.SetValue(noteTitle + "\n\n") - self.noteEditArea.SetInsertionPointEnd() - self.currentNote = currentNote - if self.currentNote: - self.noteEditArea.SetValue(self.currentNote.content) - self.noteEditArea.SetFocus() - - if self.currentNote and self.currentNote.docxPath: - # Translators: The label of the check box in Notetaker dialog when editing a note which has Word document attached to it - checkboxText = _( - "Update the corresponding Microsoft &Word document also") - else: - # Translators: The label of the check box in Notetaker dialog when creating a new note or when editing an existing note with no Word document attached to it - checkboxText = _("Save as Microsoft &Word document also") - self.saveAswordCheckBox = sHelper.addItem( - wx.CheckBox(self, label=checkboxText)) - if self.currentNote and self.currentNote.docxPath: - self.saveAswordCheckBox.Value = True - buttons = guiHelper.ButtonHelper(wx.HORIZONTAL) - saveButton = buttons.addButton( - self, - id=wx.ID_OK, - # Translators: a lable of a button in Notetaker dialog - label=_("&Save and close")) - saveButton.SetDefault() - saveButton.Bind(wx.EVT_BUTTON, self.onsaveChanges) - discardButton = buttons.addButton( - self, - id=wx.ID_CLOSE, - # Translators: a lable of a button in Notetaker dialog - label=_("&Discard")) - discardButton.Bind(wx.EVT_BUTTON, lambda evt: self.Close()) - sHelper.addDialogDismissButtons(buttons, True) - mainSizer.Add(sHelper.sizer, proportion=1, flag=wx.EXPAND) - self.SetSizer(mainSizer) - self.Bind(wx.EVT_CLOSE, self.onDiscard) - self.Bind(wx.EVT_WINDOW_DESTROY, self.onDestroy) - self.EscapeId = wx.ID_CLOSE - - def onDestroy(self, evt): - global noteTakerInstance - noteTakerInstance = None - evt.Skip() - - def onPreview(self, evt): - mdContent = self.noteEditArea.GetValue() - mdContent = handleMdContent(mdContent) - htmlContent = markdown(mdContent, extras=["markdown-in-html"]) - title = getTitle(mdContent) - ui.browseableMessage(htmlContent, title, True) - - def onsaveChanges(self, evt): - newContent = self.noteEditArea.GetValue() - if self.saveAswordCheckBox.Value: - self.saveAsWord(newContent) - return - if self.currentNote: - notesManager.updateNote(self.currentNote.id, newContent) - else: - notesManager.saveNewNote(newContent) - self._savePositionInformation() - self._clean() - - def onDiscard(self, evt): - textAreaContent = self.noteEditArea.GetValue() - if not textAreaContent: - self._savePositionInformation() - self._clean() - return - if self.currentNote and self.currentNote.content == textAreaContent: - self._savePositionInformation() - self._clean() - return - res = gui.messageBox( - # Translators: The message which asks the user whether they want to exit and discard changes in Notetaker dialog - _("Are you sure you want to exit and discard changes?"), - # Translators: The title of the message which asks the user whether they want to exit and discard changes in Notetaker dialog - _("Warning"), - style=wx.YES_NO | wx.NO_DEFAULT | wx.CANCEL, - parent=self) - if res == wx.YES: - self._savePositionInformation() - self._clean() - - def _savePositionInformation(self): - position = self.GetPosition() - addonConfig.setValue("takerXPos", position.x) - addonConfig.setValue("takerYPos", position.y) - size = self.GetSize() - addonConfig.setValue("takerWidth", size.width) - addonConfig.setValue("takerHeight", size.height) - - def onOpenManager(self, evt): - global notesManagerInstance - if notesManagerInstance: - gui.messageBox( - # Translators: the message shown to the user when opening Notes Manager is not possible because a one is already opened - _("Couldn't open Notes Manager! A Notes Manager window is already opened."), - # Translators: the title of the message telling the user that opening Notes Manager wasn't possible - _("Warning"), - style=wx.ICON_WARNING | wx.OK, - parent=self - ) - return - gui.mainFrame.prePopup() - notesManagerInstance = NotesManagerDialog() - notesManagerInstance.Show() - gui.mainFrame.postPopup() - - def onKeyUp(self, evt): - if evt.GetModifiers() == wx.MOD_CONTROL: - if evt.GetKeyCode() == ord("R"): - self.noteEditArea.SetLayoutDirection(wx.Layout_RightToLeft) - elif evt.GetKeyCode() == ord("L"): - self.noteEditArea.SetLayoutDirection(wx.Layout_LeftToRight) - evt.Skip() - - def onCharacter(self, evt): - content = self.noteEditArea.GetValue() - if not addonConfig.getValue("autoAlignText"): - evt.Skip() - return - res = handleTextAlignment( - content, self.noteEditArea.GetLayoutDirection()) - if res == Align.ALIGN_TO_LEFT: - self.noteEditArea.SetLayoutDirection(wx.Layout_LeftToRight) - elif res == Align.ALIGN_TO_RIGHT: - self.noteEditArea.SetLayoutDirection(wx.Layout_RightToLeft) - evt.Skip() - - def onCopy(self, evt): - content = self.noteEditArea.GetValue() - res = api.copyToClip(content, False) - if res == True: - # Translators: The message which tells the user that copying the note was successful - ui.message(_("Copied to clipboard!")) - - def onCopyAsHtml(self, evt): - content = self.noteEditArea.GetValue() - res = api.copyToClip( - markdown(content, extras=["markdown-in-html"]), False) - if res == True: - # Translators: The message which tells the user that copying the note was successful - ui.message(_("Copied to clipboard!")) - - def saveAsWord(self, newContent): - docxPath = "" - if self.currentNote and self.currentNote.docxPath: - docxPath = self.currentNote.docxPath - elif addonConfig.getValue("askWhereToSaveDocx"): - docxPath = askUserWhereToSave(self, newContent) - if docxPath is None: - return - saveAsWord( - newContent, - docxPath, - self._saveAsWordCallback, - self.currentNote.id if self.currentNote else None - ) - self._savePositionInformation() - self._clean() - - def _saveAsWordCallback(self, outputFilePath, dirWasChanged, mdContent, noteID): - if noteID: - notesManager.updateNote(noteID, mdContent, outputFilePath) - else: - notesManager.saveNewNote(mdContent, outputFilePath) - notifyDirWasChanged(dirWasChanged) - if notesManagerInstance: - notesManagerInstance.refreshAllNotesList( - notesManagerInstance.notesList.GetFirstSelected()) - - def _clean(self): - self.DestroyChildren() - self.Destroy() + @classmethod + def _instance(cls): + """type: () -> NoteTakerDialog + return None until this is replaced with a weakref.ref object. Then the instance is retrieved + with by treating that object as a callable. + """ + return None + + def __new__(cls, *args, **kwargs): + instance = NoteTakerDialog._instance() + if instance is None: + return super(NoteTakerDialog, cls).__new__(cls, *args, **kwargs) + return instance + + def _getDialogSizeAndPosition(self): + dialogSize = wx.Size(500, 500) + dialogPos = wx.DefaultPosition + if addonConfig.getValue("rememberTakerSizeAndPos"): + log.debug("Setting Quick Notetaker Notetaker window position and size") + dialogSize = wx.Size(addonConfig.getValue("takerWidth"), addonConfig.getValue("takerHeight")) + dialogPos = wx.Point(x=addonConfig.getValue("takerXPos"), y=addonConfig.getValue("takerYPos")) + return dialogSize, dialogPos + + def __init__(self, currentNote=None, noteTitle=None): + if NoteTakerDialog._instance() is not None: + return + NoteTakerDialog._instance = weakref.ref(self) + + dialogSize, dialogPos = self._getDialogSizeAndPosition() + # Translators: the title for the Quick Notetaker Notetaker window + title = _("Notetaker - Quick Notetaker") + if noteTitle: + title = f"{noteTitle} - {title}" + + super().__init__( + gui.mainFrame, + title=title, + size=dialogSize, + pos=dialogPos, + style=wx.CAPTION | wx.CLOSE_BOX | wx.RESIZE_BORDER | wx.STAY_ON_TOP, + ) + + mainSizer = wx.BoxSizer(wx.VERTICAL) + sHelper = guiHelper.BoxSizerHelper(self, wx.VERTICAL) + # Translators: a lable of a button in Notetaker dialog + openManagerButton = wx.Button(self, label=_("Open Notes &Manager...")) + openManagerButton.Bind(wx.EVT_BUTTON, self.onOpenManager) + sHelper.addItem(openManagerButton, flag=wx.ALIGN_CENTER) + if notesManagerInstance: + openManagerButton.Disable() + buttonsHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) + preViewButton = buttonsHelper.addButton( + self, + # Translators: a lable of a button in Notetaker dialog + label=_("P&review note..."), + ) + preViewButton.Bind(wx.EVT_BUTTON, self.onPreview) + # Translators: a lable of a button in Notetaker dialog + copyButton = buttonsHelper.addButton(self, label=_("Co&py")) + copyButton.Bind(wx.EVT_BUTTON, self.onCopy) + copyHtmlButton = buttonsHelper.addButton( + self, + # Translators: a lable of a button in Notetaker dialog + label=_("Copy &HTML code"), + ) + copyHtmlButton.Bind(wx.EVT_BUTTON, self.onCopyAsHtml) + sHelper.addItem(buttonsHelper.sizer) + + sizer = wx.BoxSizer(wx.VERTICAL) + # Translators: The lable of the note content edit area in Notetaker dialog + label = wx.StaticText(self, label=_("&Note content:")) + sizer.Add(label, flag=wx.ALIGN_CENTER_HORIZONTAL) + sizer.AddSpacer(guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_VERTICAL) + self.noteEditArea = wx.TextCtrl(self, style=wx.TE_RICH2 | wx.TE_MULTILINE) + self.noteEditArea.Bind(wx.EVT_TEXT, self.onCharacter) + self.noteEditArea.Bind(wx.EVT_KEY_UP, self.onKeyUp) + sizer.Add(self.noteEditArea, proportion=1, flag=wx.EXPAND) + sHelper.addItem(sizer, proportion=1, flag=wx.EXPAND) + if noteTitle: + self.noteEditArea.SetValue(noteTitle + "\n\n") + self.noteEditArea.SetInsertionPointEnd() + self.currentNote = currentNote + if self.currentNote: + self.noteEditArea.SetValue(self.currentNote.content) + self.noteEditArea.SetFocus() + + if self.currentNote and self.currentNote.docxPath: + # Translators: The label of the check box in Notetaker dialog when editing a note which has Word document attached to it + checkboxText = _("Update the corresponding Microsoft &Word document also") + else: + # Translators: The label of the check box in Notetaker dialog when creating a new note or when editing an existing note with no Word document attached to it + checkboxText = _("Save as Microsoft &Word document also") + self.saveAswordCheckBox = sHelper.addItem(wx.CheckBox(self, label=checkboxText)) + if self.currentNote and self.currentNote.docxPath: + self.saveAswordCheckBox.Value = True + buttons = guiHelper.ButtonHelper(wx.HORIZONTAL) + saveButton = buttons.addButton( + self, + id=wx.ID_OK, + # Translators: a lable of a button in Notetaker dialog + label=_("&Save and close"), + ) + saveButton.SetDefault() + saveButton.Bind(wx.EVT_BUTTON, self.onsaveChanges) + discardButton = buttons.addButton( + self, + id=wx.ID_CLOSE, + # Translators: a lable of a button in Notetaker dialog + label=_("&Discard"), + ) + discardButton.Bind(wx.EVT_BUTTON, lambda evt: self.Close()) + sHelper.addDialogDismissButtons(buttons, True) + mainSizer.Add(sHelper.sizer, proportion=1, flag=wx.EXPAND) + self.SetSizer(mainSizer) + self.Bind(wx.EVT_CLOSE, self.onDiscard) + self.Bind(wx.EVT_WINDOW_DESTROY, self.onDestroy) + self.EscapeId = wx.ID_CLOSE + + def onDestroy(self, evt): + global noteTakerInstance + noteTakerInstance = None + evt.Skip() + + def onPreview(self, evt): + mdContent = self.noteEditArea.GetValue() + mdContent = handleMdContent(mdContent) + htmlContent = markdown(mdContent, extras=["markdown-in-html"]) + title = getTitle(mdContent) + ui.browseableMessage(htmlContent, title, True) + + def onsaveChanges(self, evt): + newContent = self.noteEditArea.GetValue() + if self.saveAswordCheckBox.Value: + self.saveAsWord(newContent) + return + if self.currentNote: + notesManager.updateNote(self.currentNote.id, newContent) + else: + notesManager.saveNewNote(newContent) + self._savePositionInformation() + self._clean() + + def onDiscard(self, evt): + textAreaContent = self.noteEditArea.GetValue() + if not textAreaContent: + self._savePositionInformation() + self._clean() + return + if self.currentNote and self.currentNote.content == textAreaContent: + self._savePositionInformation() + self._clean() + return + res = gui.messageBox( + # Translators: The message which asks the user whether they want to exit and discard changes in Notetaker dialog + _("Are you sure you want to exit and discard changes?"), + # Translators: The title of the message which asks the user whether they want to exit and discard changes in Notetaker dialog + _("Warning"), + style=wx.YES_NO | wx.NO_DEFAULT | wx.CANCEL, + parent=self, + ) + if res == wx.YES: + self._savePositionInformation() + self._clean() + + def _savePositionInformation(self): + position = self.GetPosition() + addonConfig.setValue("takerXPos", position.x) + addonConfig.setValue("takerYPos", position.y) + size = self.GetSize() + addonConfig.setValue("takerWidth", size.width) + addonConfig.setValue("takerHeight", size.height) + + def onOpenManager(self, evt): + global notesManagerInstance + if notesManagerInstance: + gui.messageBox( + # Translators: the message shown to the user when opening Notes Manager is not possible because a one is already opened + _("Couldn't open Notes Manager! A Notes Manager window is already opened."), + # Translators: the title of the message telling the user that opening Notes Manager wasn't possible + _("Warning"), + style=wx.ICON_WARNING | wx.OK, + parent=self, + ) + return + gui.mainFrame.prePopup() + notesManagerInstance = NotesManagerDialog() + notesManagerInstance.Show() + gui.mainFrame.postPopup() + + def onKeyUp(self, evt): + if evt.GetModifiers() == wx.MOD_CONTROL: + if evt.GetKeyCode() == ord("R"): + self.noteEditArea.SetLayoutDirection(wx.Layout_RightToLeft) + elif evt.GetKeyCode() == ord("L"): + self.noteEditArea.SetLayoutDirection(wx.Layout_LeftToRight) + evt.Skip() + + def onCharacter(self, evt): + content = self.noteEditArea.GetValue() + if not addonConfig.getValue("autoAlignText"): + evt.Skip() + return + res = handleTextAlignment(content, self.noteEditArea.GetLayoutDirection()) + if res == Align.ALIGN_TO_LEFT: + self.noteEditArea.SetLayoutDirection(wx.Layout_LeftToRight) + elif res == Align.ALIGN_TO_RIGHT: + self.noteEditArea.SetLayoutDirection(wx.Layout_RightToLeft) + evt.Skip() + + def onCopy(self, evt): + content = self.noteEditArea.GetValue() + res = api.copyToClip(content, False) + if res: + # Translators: The message which tells the user that copying the note was successful + ui.message(_("Copied to clipboard!")) + + def onCopyAsHtml(self, evt): + content = self.noteEditArea.GetValue() + res = api.copyToClip(markdown(content, extras=["markdown-in-html"]), False) + if res: + # Translators: The message which tells the user that copying the note was successful + ui.message(_("Copied to clipboard!")) + + def saveAsWord(self, newContent): + docxPath = "" + if self.currentNote and self.currentNote.docxPath: + docxPath = self.currentNote.docxPath + elif addonConfig.getValue("askWhereToSaveDocx"): + docxPath = askUserWhereToSave(self, newContent) + if docxPath is None: + return + saveAsWord( + newContent, docxPath, self._saveAsWordCallback, self.currentNote.id if self.currentNote else None + ) + self._savePositionInformation() + self._clean() + + def _saveAsWordCallback(self, outputFilePath, dirWasChanged, mdContent, noteID): + if noteID: + notesManager.updateNote(noteID, mdContent, outputFilePath) + else: + notesManager.saveNewNote(mdContent, outputFilePath) + notifyDirWasChanged(dirWasChanged) + if notesManagerInstance: + notesManagerInstance.refreshAllNotesList(notesManagerInstance.notesList.GetFirstSelected()) + + def _clean(self): + self.DestroyChildren() + self.Destroy() class NotesManagerDialog( - DpiScalingHelperMixinWithoutInit, - wx.Dialog # wxPython does not seem to call base class initializer, put last in MRO + DpiScalingHelperMixinWithoutInit, + wx.Dialog, # wxPython does not seem to call base class initializer, put last in MRO ): - - @classmethod - def _instance(cls): - """ type: () -> NotesManagerDialog - return None until this is replaced with a weakref.ref object. Then the instance is retrieved - with by treating that object as a callable. - """ - return None - - def __new__(cls, *args, **kwargs): - instance = NotesManagerDialog._instance() - if instance is None: - return super(NotesManagerDialog, cls).__new__(cls, *args, **kwargs) - return instance - - def __init__(self): - if NotesManagerDialog._instance() is not None: - return - NotesManagerDialog._instance = weakref.ref(self) - -# Translators: The title of the Notes Manager dialog - title = _("Notes Manager - Quick Notetaker") - super().__init__( - gui.mainFrame, - title=title, - style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, - ) - mainSizer = wx.BoxSizer(wx.VERTICAL) - firstTextSizer = wx.BoxSizer(wx.VERTICAL) - listAndButtonsSizerHelper = guiHelper.BoxSizerHelper( - self, sizer=wx.BoxSizer(wx.HORIZONTAL)) - - # Translators: the label of the notes list in Notes Manager dialog - entriesLabel = _("No&tes:") - firstTextSizer.Add(wx.StaticText(self, label=entriesLabel)) - mainSizer.Add( - firstTextSizer, - border=guiHelper.BORDER_FOR_DIALOGS, - flag=wx.TOP | wx.LEFT | wx.RIGHT - ) - self.notesList = listAndButtonsSizerHelper.addItem( - nvdaControls.AutoWidthColumnListCtrl( - parent=self, - style=wx.LC_REPORT | wx.LC_SINGLE_SEL, - ), - flag=wx.EXPAND, - proportion=1, - ) - # Translators: the name of the first column in the notes list in Notes Manager dialog - self.notesList.InsertColumn(0, _("Title"), width=self.scaleSize(200)) - self.notesList.InsertColumn( - # Translators: the name of the second column in the notes list in Notes Manager dialog - 1, _("Last Edited"), width=self.scaleSize(100)) - # Translators: the name of the third column in the notes list in Notes Manager dialog - self.notesList.InsertColumn(2, _("Preview"), width=self.scaleSize(400)) - self.notesList.Bind(wx.EVT_LIST_ITEM_FOCUSED, self.onListItemSelected) - - # this is the group of buttons that affects the currently selected note - entryButtonsHelper = guiHelper.ButtonHelper(wx.VERTICAL) - self.viewButton = entryButtonsHelper.addButton( - self, - # Translaters: The lable of a button in Notes Manager dialog - label=_("&View note...")) - self.viewButton.Disable() - self.viewButton.Bind(wx.EVT_BUTTON, self.onView) - self.editButton = entryButtonsHelper.addButton( - self, - # Translaters: The lable of a button in Notes Manager dialog - label=_("&Edit note...")) - self.editButton.Disable() - self.editButton.Bind(wx.EVT_BUTTON, self.onEdit) - self.copyButton = entryButtonsHelper.addButton( - self, - # Translaters: The lable of a button in Notes Manager dialog - label=_("Co&py note")) - self.copyButton.Disable() - self.copyButton.Bind(wx.EVT_BUTTON, self.onCopy) - self.openInWordButton = entryButtonsHelper.addButton( - self, - # Translaters: The lable of the open in Word button in Notes Manager dialog in case the note has a Word document attached to it - label=_("&Open in Microsoft Word...")) - self.openInWordButton.Disable() - self.openInWordButton.Bind(wx.EVT_BUTTON, self.onOpenInWord) - self.copyHtmlButton = entryButtonsHelper.addButton( - self, - # Translaters: The lable of a button in Notes Manager dialog - label=_("Copy &HTML code")) - self.copyHtmlButton.Disable() - self.copyHtmlButton.Bind(wx.EVT_BUTTON, self.onCopyAsHtml) - self.deleteButton = entryButtonsHelper.addButton( - self, - # Translaters: The lable of a button in Notes Manager dialog - label=_("&Delete note...")) - self.deleteButton.Disable() - self.deleteButton.Bind(wx.EVT_BUTTON, self.onDelete) - listAndButtonsSizerHelper.addItem(entryButtonsHelper.sizer) - - mainSizer.Add( - listAndButtonsSizerHelper.sizer, - border=guiHelper.BORDER_FOR_DIALOGS, - flag=wx.ALL | wx.EXPAND, - proportion=1, - ) - - generalActions = guiHelper.ButtonHelper(wx.HORIZONTAL) - # Translators: the label of a button in Notes Manager dialog - newNoteButton = generalActions.addButton(self, label=_("&New note...")) - newNoteButton.Bind(wx.EVT_BUTTON, self.onNewNote) - if noteTakerInstance: - newNoteButton.Disable() - # Translaters: The lable of a button in Notes Manager dialog - openSettingsButton = generalActions.addButton( - self, label=_("Open &settings...")) - openSettingsButton.Bind(wx.EVT_BUTTON, self.onSettings) - mainSizer.Add( - generalActions.sizer, - border=guiHelper.BORDER_FOR_DIALOGS, - flag=wx.LEFT | wx.RIGHT - ) - - mainSizer.Add( - wx.StaticLine(self), - border=guiHelper.BORDER_FOR_DIALOGS, - flag=wx.ALL | wx.EXPAND - ) - - # Translaters: The lable of a button in Notes Manager dialog - closeButton = wx.Button(self, label=_("&Close"), id=wx.ID_CLOSE) - closeButton.Bind(wx.EVT_BUTTON, lambda evt: self.Close()) - mainSizer.Add( - closeButton, - border=guiHelper.BORDER_FOR_DIALOGS, - flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.CENTER | wx.ALIGN_RIGHT - ) - self.Bind(wx.EVT_CLOSE, self.onClose) - self.EscapeId = wx.ID_CLOSE - - mainSizer.Fit(self) - self.SetSizer(mainSizer) - - self.refreshAllNotesList() - self.SetMinSize(mainSizer.GetMinSize()) - self.SetSize(self.scaleSize((763, 509))) - self.CentreOnScreen() - self.notesList.SetFocus() - self.Bind(wx.EVT_ACTIVATE, self.onActivate) - self.Bind(wx.EVT_WINDOW_DESTROY, self.onDestroy) - - def onActivate(self, evt): - if evt.GetActive(): - self.refreshAllNotesList(self.notesList.GetFirstSelected()) - evt.Skip() - - def onDestroy(self, evt): - global notesManagerInstance - notesManagerInstance = None - evt.Skip() - - def onView(self, evt): - curNote = self._getCurrentNote() - if not curNote: - return - content = handleMdContent(curNote.content) - contentAsHtml = markdown(content, extras=["markdown-in-html"]) - ui.browseableMessage(contentAsHtml, curNote.title, True) - - def onEdit(self, evt): - curNote = self._getCurrentNote() - if not curNote: - return - global noteTakerInstance - if noteTakerInstance: - gui.messageBox( - # Translators: the message shown to the user when editing the note is not possible - _("Couldn't edit note! An open Notetaker window with unsaved changes is present."), - # Translators: the title of the message telling the user that editing the note wasn't possible - _("Warning"), - style=wx.ICON_WARNING | wx.OK, - parent=self - ) - return - gui.mainFrame.prePopup() - noteTakerInstance = NoteTakerDialog(currentNote=curNote) - noteTakerInstance.Show() - gui.mainFrame.postPopup() - - def _getCurrentNote(self): - index = self.notesList.GetFirstSelected() - if index < 0: - return - curNote = notesManager.loadAllNotes()[index] - return curNote - - def onNewNote(self, evt): - global noteTakerInstance - if noteTakerInstance: - gui.messageBox( - # Translators: the message shown to the user when opening Notetaker is not possible because a one is already opened - _("Couldn't open Notetaker! A Notetaker window is already opened."), - # Translators: the title of the message telling the user that opening Notetaker wasn't possible - _("Warning"), - style=wx.ICON_WARNING | wx.OK, - parent=self - ) - return - gui.mainFrame.prePopup() - noteTakerInstance = NoteTakerDialog() - noteTakerInstance.Show() - gui.mainFrame.postPopup() - - def onSettings(self, evt): - gui.mainFrame._popupSettingsDialog( - NVDASettingsDialog, QuickNotetakerPanel) - - def onClose(self, evt): - self.DestroyChildren() - self.Destroy() - evt.Skip() - - def onListItemSelected(self, evt): - self.viewButton.Enable() - self.editButton.Enable() - self.copyButton.Enable() - curNote = self._getCurrentNote() - if curNote and curNote.docxPath: - # Translators: the lable of the open in word button in Notes Manager dialog in case the note has a Word document attached to it - label = _("&Open in Microsoft Word...") - else: - # Translators: the lable of the open in word button in Notes Manager dialog in case the note has no Word document attached to it - label = _("Create Microsoft &Word document") - if addonConfig.getValue("askWhereToSaveDocx"): - label += "..." - self.openInWordButton.SetLabel(label) - self.openInWordButton.Enable() - self.copyHtmlButton.Enable() - self.deleteButton.Enable() - - def refreshAllNotesList(self, activeIndex=0): - self.notesList.DeleteAllItems() - for note in notesManager.loadAllNotes(): - self.notesList.Append(( - note.title, - note.lastEdited, - getPreviewText(note.content) - )) - # Select the given active note or the first one if not given - allNotesLen = len(notesManager.loadAllNotes()) - if allNotesLen > 0: - if activeIndex == -1: - activeIndex = allNotesLen - 1 - elif activeIndex < 0 or activeIndex >= allNotesLen: - activeIndex = 0 - self.notesList.Select(activeIndex, on=1) - self.notesList.SetItemState( - activeIndex, wx.LIST_STATE_FOCUSED, wx.LIST_STATE_FOCUSED) - else: - self.viewButton.Disable() - self.editButton.Disable() - self.copyButton.Disable() - self.openInWordButton.Disable() - self.copyHtmlButton.Disable() - self.deleteButton.Disable() - - def onOpenInWord(self, evt): - curNote = self._getCurrentNote() - if curNote and curNote.docxPath: - openInWord( - curNote.docxPath, - self._openInWordCallback, - curNote.id - ) - return - if not curNote: - return - docxPath = "" - if addonConfig.getValue("askWhereToSaveDocx"): - docxPath = askUserWhereToSave(self, curNote.content) - if docxPath is None: - return - saveAsWord( - curNote.content, - docxPath, - self._saveAsWordCallback, - curNote.id - ) - - def _saveAsWordCallback(self, outputFilePath, dirWasChanged, mdContent, noteID): - notesManager.updateNote(noteID, docxPath=outputFilePath) - notifyDirWasChanged(dirWasChanged) - if not self: - return - self.refreshAllNotesList(self.notesList.GetFirstSelected()) - - def _openInWordCallback(self, hasSucceeded, noteID): - if hasSucceeded: - return - notesManager.updateNote(noteID, docxPath="") - gui.messageBox( - # Translators: the message shown to the user when the note attached Word document is no longer available. - # This message is displayed when trying to open the note's Word document from the Notes Manager dialog - _("A document with the specified name was not found! You can create a new one so you would be able to view this note as a Microsoft Word document."), - # Translators: the title of the message shown to the user when the note attached Word document is no longer available. - # This message is displayed when trying to open the note's Word document from the Notes Manager dialog - _("Warning"), - style=wx.ICON_WARNING | wx.OK, - parent=gui.mainFrame - ) - - def onDelete(self, evt): - curNote = self._getCurrentNote() - if not curNote: - return - res = gui.messageBox( - # Translators: the warning messaged shown to the user when they try to delete a note from Notes Manager - _("Are you sure you want to delete this note?"), - # Translators: the title of the warning messaged shown to the user when they try to delete a note from Notes Manager - _("Warning"), - style=wx.YES_NO | wx.NO_DEFAULT, - parent=self) - if res != wx.YES: - return - notesManager.deleteNote(curNote.id) - self.refreshAllNotesList(self.notesList.GetFirstSelected()) - - def onCopy(self, evt): - curNote = self._getCurrentNote() - if not curNote: - return - res = api.copyToClip(curNote.content, False) - if res == True: - # Translators: the message telling the user that copying the note was successful - ui.message(_("Copied to clipboard!")) - - def onCopyAsHtml(self, evt): - curNote = self._getCurrentNote() - if not curNote: - return - res = api.copyToClip( - markdown(curNote.content, extras=["markdown-in-html"]), False) - if res: - # Translators: the message telling the user that copying the note was successful - ui.message(_("Copied to clipboard!")) + @classmethod + def _instance(cls): + """type: () -> NotesManagerDialog + return None until this is replaced with a weakref.ref object. Then the instance is retrieved + with by treating that object as a callable. + """ + return None + + def __new__(cls, *args, **kwargs): + instance = NotesManagerDialog._instance() + if instance is None: + return super(NotesManagerDialog, cls).__new__(cls, *args, **kwargs) + return instance + + def __init__(self): + if NotesManagerDialog._instance() is not None: + return + NotesManagerDialog._instance = weakref.ref(self) + + # Translators: The title of the Notes Manager dialog + title = _("Notes Manager - Quick Notetaker") + super().__init__( + gui.mainFrame, + title=title, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX, + ) + mainSizer = wx.BoxSizer(wx.VERTICAL) + firstTextSizer = wx.BoxSizer(wx.VERTICAL) + listAndButtonsSizerHelper = guiHelper.BoxSizerHelper(self, sizer=wx.BoxSizer(wx.HORIZONTAL)) + + # Translators: the label of the notes list in Notes Manager dialog + entriesLabel = _("No&tes:") + firstTextSizer.Add(wx.StaticText(self, label=entriesLabel)) + mainSizer.Add(firstTextSizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.TOP | wx.LEFT | wx.RIGHT) + self.notesList = listAndButtonsSizerHelper.addItem( + nvdaControls.AutoWidthColumnListCtrl( + parent=self, + style=wx.LC_REPORT | wx.LC_SINGLE_SEL, + ), + flag=wx.EXPAND, + proportion=1, + ) + # Translators: the name of the first column in the notes list in Notes Manager dialog + self.notesList.InsertColumn(0, _("Title"), width=self.scaleSize(200)) + self.notesList.InsertColumn( + # Translators: the name of the second column in the notes list in Notes Manager dialog + 1, + _("Last Edited"), + width=self.scaleSize(100), + ) + # Translators: the name of the third column in the notes list in Notes Manager dialog + self.notesList.InsertColumn(2, _("Preview"), width=self.scaleSize(400)) + self.notesList.Bind(wx.EVT_LIST_ITEM_FOCUSED, self.onListItemSelected) + + # this is the group of buttons that affects the currently selected note + entryButtonsHelper = guiHelper.ButtonHelper(wx.VERTICAL) + self.viewButton = entryButtonsHelper.addButton( + self, + # Translaters: The lable of a button in Notes Manager dialog + label=_("&View note..."), + ) + self.viewButton.Disable() + self.viewButton.Bind(wx.EVT_BUTTON, self.onView) + self.editButton = entryButtonsHelper.addButton( + self, + # Translaters: The lable of a button in Notes Manager dialog + label=_("&Edit note..."), + ) + self.editButton.Disable() + self.editButton.Bind(wx.EVT_BUTTON, self.onEdit) + self.copyButton = entryButtonsHelper.addButton( + self, + # Translaters: The lable of a button in Notes Manager dialog + label=_("Co&py note"), + ) + self.copyButton.Disable() + self.copyButton.Bind(wx.EVT_BUTTON, self.onCopy) + self.openInWordButton = entryButtonsHelper.addButton( + self, + # Translaters: The lable of the open in Word button in Notes Manager dialog in case the note has a Word document attached to it + label=_("&Open in Microsoft Word..."), + ) + self.openInWordButton.Disable() + self.openInWordButton.Bind(wx.EVT_BUTTON, self.onOpenInWord) + self.copyHtmlButton = entryButtonsHelper.addButton( + self, + # Translaters: The lable of a button in Notes Manager dialog + label=_("Copy &HTML code"), + ) + self.copyHtmlButton.Disable() + self.copyHtmlButton.Bind(wx.EVT_BUTTON, self.onCopyAsHtml) + self.deleteButton = entryButtonsHelper.addButton( + self, + # Translaters: The lable of a button in Notes Manager dialog + label=_("&Delete note..."), + ) + self.deleteButton.Disable() + self.deleteButton.Bind(wx.EVT_BUTTON, self.onDelete) + listAndButtonsSizerHelper.addItem(entryButtonsHelper.sizer) + + mainSizer.Add( + listAndButtonsSizerHelper.sizer, + border=guiHelper.BORDER_FOR_DIALOGS, + flag=wx.ALL | wx.EXPAND, + proportion=1, + ) + + generalActions = guiHelper.ButtonHelper(wx.HORIZONTAL) + # Translators: the label of a button in Notes Manager dialog + newNoteButton = generalActions.addButton(self, label=_("&New note...")) + newNoteButton.Bind(wx.EVT_BUTTON, self.onNewNote) + if noteTakerInstance: + newNoteButton.Disable() + # Translaters: The lable of a button in Notes Manager dialog + openSettingsButton = generalActions.addButton(self, label=_("Open &settings...")) + openSettingsButton.Bind(wx.EVT_BUTTON, self.onSettings) + mainSizer.Add(generalActions.sizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.LEFT | wx.RIGHT) + + mainSizer.Add(wx.StaticLine(self), border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL | wx.EXPAND) + + # Translaters: The lable of a button in Notes Manager dialog + closeButton = wx.Button(self, label=_("&Close"), id=wx.ID_CLOSE) + closeButton.Bind(wx.EVT_BUTTON, lambda evt: self.Close()) + mainSizer.Add( + closeButton, + border=guiHelper.BORDER_FOR_DIALOGS, + flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.CENTER | wx.ALIGN_RIGHT, + ) + self.Bind(wx.EVT_CLOSE, self.onClose) + self.EscapeId = wx.ID_CLOSE + + mainSizer.Fit(self) + self.SetSizer(mainSizer) + + self.refreshAllNotesList() + self.SetMinSize(mainSizer.GetMinSize()) + self.SetSize(self.scaleSize((763, 509))) + self.CentreOnScreen() + self.notesList.SetFocus() + self.Bind(wx.EVT_ACTIVATE, self.onActivate) + self.Bind(wx.EVT_WINDOW_DESTROY, self.onDestroy) + + def onActivate(self, evt): + if evt.GetActive(): + self.refreshAllNotesList(self.notesList.GetFirstSelected()) + evt.Skip() + + def onDestroy(self, evt): + global notesManagerInstance + notesManagerInstance = None + evt.Skip() + + def onView(self, evt): + curNote = self._getCurrentNote() + if not curNote: + return + content = handleMdContent(curNote.content) + contentAsHtml = markdown(content, extras=["markdown-in-html"]) + ui.browseableMessage(contentAsHtml, curNote.title, True) + + def onEdit(self, evt): + curNote = self._getCurrentNote() + if not curNote: + return + global noteTakerInstance + if noteTakerInstance: + gui.messageBox( + # Translators: the message shown to the user when editing the note is not possible + _("Couldn't edit note! An open Notetaker window with unsaved changes is present."), + # Translators: the title of the message telling the user that editing the note wasn't possible + _("Warning"), + style=wx.ICON_WARNING | wx.OK, + parent=self, + ) + return + gui.mainFrame.prePopup() + noteTakerInstance = NoteTakerDialog(currentNote=curNote) + noteTakerInstance.Show() + gui.mainFrame.postPopup() + + def _getCurrentNote(self): + index = self.notesList.GetFirstSelected() + if index < 0: + return + curNote = notesManager.loadAllNotes()[index] + return curNote + + def onNewNote(self, evt): + global noteTakerInstance + if noteTakerInstance: + gui.messageBox( + # Translators: the message shown to the user when opening Notetaker is not possible because a one is already opened + _("Couldn't open Notetaker! A Notetaker window is already opened."), + # Translators: the title of the message telling the user that opening Notetaker wasn't possible + _("Warning"), + style=wx.ICON_WARNING | wx.OK, + parent=self, + ) + return + gui.mainFrame.prePopup() + noteTakerInstance = NoteTakerDialog() + noteTakerInstance.Show() + gui.mainFrame.postPopup() + + def onSettings(self, evt): + gui.mainFrame._popupSettingsDialog(NVDASettingsDialog, QuickNotetakerPanel) + + def onClose(self, evt): + self.DestroyChildren() + self.Destroy() + evt.Skip() + + def onListItemSelected(self, evt): + self.viewButton.Enable() + self.editButton.Enable() + self.copyButton.Enable() + curNote = self._getCurrentNote() + if curNote and curNote.docxPath: + # Translators: the lable of the open in word button in Notes Manager dialog in case the note has a Word document attached to it + label = _("&Open in Microsoft Word...") + else: + # Translators: the lable of the open in word button in Notes Manager dialog in case the note has no Word document attached to it + label = _("Create Microsoft &Word document") + if addonConfig.getValue("askWhereToSaveDocx"): + label += "..." + self.openInWordButton.SetLabel(label) + self.openInWordButton.Enable() + self.copyHtmlButton.Enable() + self.deleteButton.Enable() + + def refreshAllNotesList(self, activeIndex=0): + self.notesList.DeleteAllItems() + for note in notesManager.loadAllNotes(): + self.notesList.Append((note.title, note.lastEdited, getPreviewText(note.content))) + # Select the given active note or the first one if not given + allNotesLen = len(notesManager.loadAllNotes()) + if allNotesLen > 0: + if activeIndex == -1: + activeIndex = allNotesLen - 1 + elif activeIndex < 0 or activeIndex >= allNotesLen: + activeIndex = 0 + self.notesList.Select(activeIndex, on=1) + self.notesList.SetItemState(activeIndex, wx.LIST_STATE_FOCUSED, wx.LIST_STATE_FOCUSED) + else: + self.viewButton.Disable() + self.editButton.Disable() + self.copyButton.Disable() + self.openInWordButton.Disable() + self.copyHtmlButton.Disable() + self.deleteButton.Disable() + + def onOpenInWord(self, evt): + curNote = self._getCurrentNote() + if curNote and curNote.docxPath: + openInWord(curNote.docxPath, self._openInWordCallback, curNote.id) + return + if not curNote: + return + docxPath = "" + if addonConfig.getValue("askWhereToSaveDocx"): + docxPath = askUserWhereToSave(self, curNote.content) + if docxPath is None: + return + saveAsWord(curNote.content, docxPath, self._saveAsWordCallback, curNote.id) + + def _saveAsWordCallback(self, outputFilePath, dirWasChanged, mdContent, noteID): + notesManager.updateNote(noteID, docxPath=outputFilePath) + notifyDirWasChanged(dirWasChanged) + if not self: + return + self.refreshAllNotesList(self.notesList.GetFirstSelected()) + + def _openInWordCallback(self, hasSucceeded, noteID): + if hasSucceeded: + return + notesManager.updateNote(noteID, docxPath="") + gui.messageBox( + # Translators: the message shown to the user when the note attached Word document is no longer available. + # This message is displayed when trying to open the note's Word document from the Notes Manager dialog + _( + "A document with the specified name was not found! You can create a new one so you would be able to view this note as a Microsoft Word document." + ), + # Translators: the title of the message shown to the user when the note attached Word document is no longer available. + # This message is displayed when trying to open the note's Word document from the Notes Manager dialog + _("Warning"), + style=wx.ICON_WARNING | wx.OK, + parent=gui.mainFrame, + ) + + def onDelete(self, evt): + curNote = self._getCurrentNote() + if not curNote: + return + res = gui.messageBox( + # Translators: the warning messaged shown to the user when they try to delete a note from Notes Manager + _("Are you sure you want to delete this note?"), + # Translators: the title of the warning messaged shown to the user when they try to delete a note from Notes Manager + _("Warning"), + style=wx.YES_NO | wx.NO_DEFAULT, + parent=self, + ) + if res != wx.YES: + return + notesManager.deleteNote(curNote.id) + self.refreshAllNotesList(self.notesList.GetFirstSelected()) + + def onCopy(self, evt): + curNote = self._getCurrentNote() + if not curNote: + return + res = api.copyToClip(curNote.content, False) + if res: + # Translators: the message telling the user that copying the note was successful + ui.message(_("Copied to clipboard!")) + + def onCopyAsHtml(self, evt): + curNote = self._getCurrentNote() + if not curNote: + return + res = api.copyToClip(markdown(curNote.content, extras=["markdown-in-html"]), False) + if res: + # Translators: the message telling the user that copying the note was successful + ui.message(_("Copied to clipboard!")) def notifyDirWasChanged(dirWasChanged): - if dirWasChanged: - gui.messageBox( - # Translators: the message which tells the user that the directory they tried to save the file in is no longer available, - # so the file was saved in the user default one if this was possible. - # If not, the file was saved in the quick Notetaker directory in documents folder - _("The saved path for the Microsoft Word document no longer exists! The document was saved in the default directory for the ad-on!"), - # Translators: the title of the message telling the user that the directory they tried to save the document in is no longer available. - # See the message body for more details - _("Warning"), - style=wx.ICON_WARNING | wx.OK, - parent=gui.mainFrame) + if dirWasChanged: + gui.messageBox( + # Translators: the message which tells the user that the directory they tried to save the file in is no longer available, + # so the file was saved in the user default one if this was possible. + # If not, the file was saved in the quick Notetaker directory in documents folder + _( + "The saved path for the Microsoft Word document no longer exists! The document was saved in the default directory for the ad-on!" + ), + # Translators: the title of the message telling the user that the directory they tried to save the document in is no longer available. + # See the message body for more details + _("Warning"), + style=wx.ICON_WARNING | wx.OK, + parent=gui.mainFrame, + ) def askUserWhereToSave(parent, noteContent): - # Translators: The title of the dialog which allows the user to choose the folder where they want to save the note's corresponding Word document. - # This dialog is displayed to the user if the option of "Ask me each time where to save the note's corresponding Word document" in quick Notetaker settings is checked - with wx.DirDialog(parent, _("Select the folder where the document will be saved"), defaultPath=addonConfig.getValue("notesDocumentsPath")) as d: - if d.ShowModal() == wx.ID_OK: - return f"{d.Path}/{getTitle(noteContent)}.docx" - else: - return None + # Translators: The title of the dialog which allows the user to choose the folder where they want to save the note's corresponding Word document. + # This dialog is displayed to the user if the option of "Ask me each time where to save the note's corresponding Word document" in quick Notetaker settings is checked + with wx.DirDialog( + parent, + _("Select the folder where the document will be saved"), + defaultPath=addonConfig.getValue("notesDocumentsPath"), + ) as d: + if d.ShowModal() == wx.ID_OK: + return f"{d.Path}/{getTitle(noteContent)}.docx" + else: + return None diff --git a/addon/globalPlugins/quickNotetaker/helpers.py b/addon/globalPlugins/quickNotetaker/helpers.py index 1dcb1a7..7bd5f3e 100644 --- a/addon/globalPlugins/quickNotetaker/helpers.py +++ b/addon/globalPlugins/quickNotetaker/helpers.py @@ -23,243 +23,250 @@ def saveAsWord(mdContent, filePath, callback, *args): - saveThread = threading.Thread(target=_saveAsWord, args=( - mdContent, filePath, callback, *args), daemon=True) - saveThread.start() + saveThread = threading.Thread( + target=_saveAsWord, args=(mdContent, filePath, callback, *args), daemon=True + ) + saveThread.start() def _saveAsWord(mdContent, filePath, callBack, *args): - title = getTitle(mdContent) - if not os.path.isdir(TEMP_FILES_PATH): - os.mkdir(TEMP_FILES_PATH) - with open(f"{TEMP_FILES_PATH}/{title}.md", mode="w+", encoding="utf8") as input: - input.write(handleMdContent(mdContent)) - dirWasChanged = False - if filePath == "": - outputFilePath, dirWasChanged = _findAvailablePath( - addonConfig.getValue("notesDocumentsPath"), title, "docx") - else: - outputFilePath = filePath - outputFilePath, result = _runPandocCommand( - title, outputFilePath, isRtlDocument(mdContent)) - dirWasChanged = dirWasChanged or result - if callBack: - callBack(outputFilePath, dirWasChanged, mdContent, *args) - if addonConfig.getValue("openFileAfterCreation"): - os.startfile(outputFilePath) + title = getTitle(mdContent) + if not os.path.isdir(TEMP_FILES_PATH): + os.mkdir(TEMP_FILES_PATH) + with open(f"{TEMP_FILES_PATH}/{title}.md", mode="w+", encoding="utf8") as input: + input.write(handleMdContent(mdContent)) + dirWasChanged = False + if filePath == "": + outputFilePath, dirWasChanged = _findAvailablePath( + addonConfig.getValue("notesDocumentsPath"), title, "docx" + ) + else: + outputFilePath = filePath + outputFilePath, result = _runPandocCommand(title, outputFilePath, isRtlDocument(mdContent)) + dirWasChanged = dirWasChanged or result + if callBack: + callBack(outputFilePath, dirWasChanged, mdContent, *args) + if addonConfig.getValue("openFileAfterCreation"): + os.startfile(outputFilePath) def _runPandocCommand(fileTitle, outputFilePath, isHtmlDocument): - def build_pandoc_args(input_path, output_path, is_html): - args = [PANDOC_PATH, "-f", "markdown", "-t", "docx", "-s", "-i", input_path] - if is_html: - args.extend(["-V", "dir[=rtl]"]) - args.extend(["-o", output_path]) - return args - - input_md = f"{TEMP_FILES_PATH}/{fileTitle}.md" - startupInfo = subprocess.STARTUPINFO() - startupInfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - - # Try main output path - args = build_pandoc_args(input_md, outputFilePath, isHtmlDocument) - try: - subprocess.run(args, check=True, startupinfo=startupInfo, capture_output=True) - return outputFilePath, False - except subprocess.CalledProcessError as e: - log.error(f"Pandoc failed for {outputFilePath}: {e.stderr.decode(errors='ignore')}") - - # Try user documents path - user_docs = addonConfig.getValue("notesDocumentsPath") - if not os.path.isdir(user_docs): - os.mkdir(user_docs) - log.debug("the specified directory name is invalid! Reverting to the user default one.") - outputFilePath = os.path.join(user_docs, f"{fileTitle}.docx") - args = build_pandoc_args(input_md, outputFilePath, isHtmlDocument) - try: - subprocess.run(args, check=True, startupinfo=startupInfo, capture_output=True) - return outputFilePath, True - except subprocess.CalledProcessError as e: - log.error(f"Pandoc failed for {outputFilePath}: {e.stderr.decode(errors='ignore')}") - - # Try add-on default documents path - if not os.path.isdir(DEFAULT_DOCUMENTS_PATH): - os.mkdir(DEFAULT_DOCUMENTS_PATH) - log.debug("The specified directory name is invalid! Reverting to the add-on default one.") - outputFilePath = os.path.join(DEFAULT_DOCUMENTS_PATH, f"{fileTitle}.docx") - args = build_pandoc_args(input_md, outputFilePath, isHtmlDocument) - try: - subprocess.run(args, check=True, startupinfo=startupInfo, capture_output=True) - return outputFilePath, True - except subprocess.CalledProcessError as e: - log.error(f"Pandoc failed for {outputFilePath}: {e.stderr.decode(errors='ignore')}") - raise + def build_pandoc_args(input_path, output_path, is_html): + args = [PANDOC_PATH, "-f", "markdown", "-t", "docx", "-s", "-i", input_path] + if is_html: + args.extend(["-V", "dir[=rtl]"]) + args.extend(["-o", output_path]) + return args + + input_md = f"{TEMP_FILES_PATH}/{fileTitle}.md" + startupInfo = subprocess.STARTUPINFO() + startupInfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + # Try main output path + args = build_pandoc_args(input_md, outputFilePath, isHtmlDocument) + try: + subprocess.run(args, check=True, startupinfo=startupInfo, capture_output=True) + return outputFilePath, False + except subprocess.CalledProcessError as e: + log.error(f"Pandoc failed for {outputFilePath}: {e.stderr.decode(errors='ignore')}") + + # Try user documents path + user_docs = addonConfig.getValue("notesDocumentsPath") + if not os.path.isdir(user_docs): + os.mkdir(user_docs) + log.debug("the specified directory name is invalid! Reverting to the user default one.") + outputFilePath = os.path.join(user_docs, f"{fileTitle}.docx") + args = build_pandoc_args(input_md, outputFilePath, isHtmlDocument) + try: + subprocess.run(args, check=True, startupinfo=startupInfo, capture_output=True) + return outputFilePath, True + except subprocess.CalledProcessError as e: + log.error(f"Pandoc failed for {outputFilePath}: {e.stderr.decode(errors='ignore')}") + + # Try add-on default documents path + if not os.path.isdir(DEFAULT_DOCUMENTS_PATH): + os.mkdir(DEFAULT_DOCUMENTS_PATH) + log.debug("The specified directory name is invalid! Reverting to the add-on default one.") + outputFilePath = os.path.join(DEFAULT_DOCUMENTS_PATH, f"{fileTitle}.docx") + args = build_pandoc_args(input_md, outputFilePath, isHtmlDocument) + try: + subprocess.run(args, check=True, startupinfo=startupInfo, capture_output=True) + return outputFilePath, True + except subprocess.CalledProcessError as e: + log.error(f"Pandoc failed for {outputFilePath}: {e.stderr.decode(errors='ignore')}") + raise def openInWord(filePath, callback, *args): - openThread = threading.Thread( - target=_openInWord, args=(filePath, callback, *args,), daemon=True) - openThread.start() + openThread = threading.Thread( + target=_openInWord, + args=( + filePath, + callback, + *args, + ), + daemon=True, + ) + openThread.start() def _openInWord(filePath, callback, *args): - result = False - try: - os.startfile(filePath) - result = True - except: - pass - if callback: - callback(result, *args) + result = False + try: + os.startfile(filePath) + result = True + except Exception: + pass + if callback: + callback(result, *args) def _findAvailablePath(dirName, fileTitle, extension): - """Finds available file path if the given one is already used. - We need this to avoid over riding existing files content""" - dirWasChanged = False - if not os.path.isdir(dirName): - try: - os.mkdir(dirName) - except: - log.debug( - "The user default directory name is invalid! Reverting to the user default one.") - if not os.path.isdir(DEFAULT_DOCUMENTS_PATH): - os.mkdir(DEFAULT_DOCUMENTS_PATH) - dirName = DEFAULT_DOCUMENTS_PATH - dirWasChanged = True - candidatePath = os.path.join(dirName, f"{fileTitle}.{extension}") - if not os.path.isfile(candidatePath): - return candidatePath, dirWasChanged - for i in range(50): - candidatePath = os.path.join( - dirName, f"{fileTitle} ({i + 1}).{extension}") - if not os.path.isfile(candidatePath): - return candidatePath, dirWasChanged + """Finds available file path if the given one is already used. + We need this to avoid over riding existing files content""" + dirWasChanged = False + if not os.path.isdir(dirName): + try: + os.mkdir(dirName) + except Exception: + log.debug("The user default directory name is invalid! Reverting to the user default one.") + if not os.path.isdir(DEFAULT_DOCUMENTS_PATH): + os.mkdir(DEFAULT_DOCUMENTS_PATH) + dirName = DEFAULT_DOCUMENTS_PATH + dirWasChanged = True + candidatePath = os.path.join(dirName, f"{fileTitle}.{extension}") + if not os.path.isfile(candidatePath): + return candidatePath, dirWasChanged + for i in range(50): + candidatePath = os.path.join(dirName, f"{fileTitle} ({i + 1}).{extension}") + if not os.path.isfile(candidatePath): + return candidatePath, dirWasChanged #: A regex for matching a URL #: Taken from https://gist.githubusercontent.com/nishad/ff5d02394afaf8cca5818f023fb88a21/raw/cc631328b9bfc0750379847ecbe415b4df69aa67/urlmarker.py -urlPatternText =\ - r"""(?i)\b((?:https?:(?:/{1,3}|[a-z0-9%])|[a-z0-9.\-]+[.](?:com|net|org|edu|gov|mil|aero|asia|biz|cat|coop|info|int|jobs|mobi|museum|name|post|pro|tel|travel|xxx|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|Ja|sk|sl|sm|sn|so|sr|ss|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|yu|za|zm|zw)/)(?:[^\s()<>{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’])|(?:(?{}\[\]]+|\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\))+(?:\([^\s()]*?\([^\s()]+\)[^\s()]*?\)|\([^\s]+?\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’])|(?:(?)+", " ", htmlText) - # Remove extra spaces if any - extracted = _removeExtraSpaces(extracted) - return extracted + """Returns the inner text of all HTML tags found in htmlText string""" + extracted = re.sub(r"(<.*?\s*?/{0,1}>)+", " ", htmlText) + # Remove extra spaces if any + extracted = _removeExtraSpaces(extracted) + return extracted def _removeExtraSpaces(text): - text = re.sub(r" +", " ", text) - text = text.strip() - return text + text = re.sub(r" +", " ", text) + text = text.strip() + return text def _isRtlParagraph(paragraph): - """determines if the given paragraph is rtl by relying on the first none html text letter of the paragraph""" - rtlClasses = ["R", "AL", "AN"] - # Delete HTML markup if it exists - paragraphWithoutHtml = retrieveTextFromHtml(paragraph) - lettersOnly = re.sub(r"\W+", "", paragraphWithoutHtml) - if not lettersOnly: - return None - if unicodedata.bidirectional(lettersOnly[0]) in rtlClasses: - return True - else: - return False + """determines if the given paragraph is rtl by relying on the first none html text letter of the paragraph""" + rtlClasses = ["R", "AL", "AN"] + # Delete HTML markup if it exists + paragraphWithoutHtml = retrieveTextFromHtml(paragraph) + lettersOnly = re.sub(r"\W+", "", paragraphWithoutHtml) + if not lettersOnly: + return None + if unicodedata.bidirectional(lettersOnly[0]) in rtlClasses: + return True + else: + return False def _makeRtl(content): - """Makes the text rtl if needed by wrapping each paragraph starting with RTL char with a div which has dir = rtl""" - paragraphs = content.split("\n\n") - result = [] - for paragraph in paragraphs: - if _isRtlParagraph(paragraph): - result.append( - '
s around - "paragraphs" that are wrapped in non-block-level tags, such as anchors, - phrase emphasis, and spans. The list of tags we're looking for is - hard-coded. - - @param raw {boolean} indicates if these are raw HTML blocks in - the original source. It makes a difference in "safe" mode. """ - if '<' not in text: - return text - - # Pass `raw` value into our calls to self._hash_html_block_sub. - hash_html_block_sub = _curry(self._hash_html_block_sub, raw=raw) - - # First, look for nested blocks, e.g.: - #
tag:
- #
- #
- #
s around + "paragraphs" that are wrapped in non-block-level tags, such as anchors, + phrase emphasis, and spans. The list of tags we're looking for is + hard-coded. + + @param raw {boolean} indicates if these are raw HTML blocks in + the original source. It makes a difference in "safe" mode. + """ + if "<" not in text: + return text + + # Pass `raw` value into our calls to self._hash_html_block_sub. + hash_html_block_sub = _curry(self._hash_html_block_sub, raw=raw) + + # First, look for nested blocks, e.g.: + #
tag:
+ #
+ #
+ #
tags around block-level tags.
+ text = self._hash_html_blocks(text)
- text = self._form_paragraphs(text)
+ text = self._form_paragraphs(text)
- return text
+ return text
- @mark_stage(Stage.SPAN_GAMUT)
- def _run_span_gamut(self, text: str) -> str:
- # These are all the transformations that occur *within* block-level
- # tags like paragraphs, headers, and list items.
+ @mark_stage(Stage.SPAN_GAMUT)
+ def _run_span_gamut(self, text: str) -> str:
+ # These are all the transformations that occur *within* block-level
+ # tags like paragraphs, headers, and list items.
- text = self._do_code_spans(text)
+ text = self._do_code_spans(text)
- text = self._escape_special_chars(text)
+ text = self._escape_special_chars(text)
- # Process anchor and image tags.
- text = self._do_links(text)
+ # Process anchor and image tags.
+ text = self._do_links(text)
- # Make links out of things like ` Just type Just type tags.
- grafs = []
- for i, graf in enumerate(re.split(r"\n{2,}", text)):
- if graf in self.html_blocks:
- # Unhashify HTML blocks
- grafs.append(self.html_blocks[graf])
- else:
- cuddled_list = None
- if "cuddled-lists" in self.extras:
- # Need to put back trailing '\n' for `_list_item_re`
- # match at the end of the paragraph.
- li = self._list_item_re.search(graf + '\n')
- # Two of the same list marker in this paragraph: a likely
- # candidate for a list cuddled to preceding paragraph
- # text (issue 33). Note the `[-1]` is a quick way to
- # consider numeric bullets (e.g. "1." and "2.") to be
- # equal.
- if (li and len(li.group(2)) <= 3
- and (
- (li.group("next_marker") and li.group("marker")[-1] == li.group("next_marker")[-1])
- or
- li.group("next_marker") is None
- )
- ):
- start = li.start()
- cuddled_list = self._do_lists(graf[start:]).rstrip("\n")
- if re.match(r'^<(?:ul|ol).*?>', cuddled_list):
- graf = graf[:start]
- else:
- # Not quite a cuddled list. (See not_quite_a_list_cuddled_lists test case)
- # Store as a simple paragraph.
- graf = cuddled_list
- cuddled_list = None
-
- # Wrap tags.
- graf = self._run_span_gamut(graf)
- grafs.append(" " % self._html_class_str_from_tag('p') + graf.lstrip(" \t") + " %s tags.
+ grafs = []
+ for i, graf in enumerate(re.split(r"\n{2,}", text)):
+ if graf in self.html_blocks:
+ # Unhashify HTML blocks
+ grafs.append(self.html_blocks[graf])
+ else:
+ cuddled_list = None
+ if "cuddled-lists" in self.extras:
+ # Need to put back trailing '\n' for `_list_item_re`
+ # match at the end of the paragraph.
+ li = self._list_item_re.search(graf + "\n")
+ # Two of the same list marker in this paragraph: a likely
+ # candidate for a list cuddled to preceding paragraph
+ # text (issue 33). Note the `[-1]` is a quick way to
+ # consider numeric bullets (e.g. "1." and "2.") to be
+ # equal.
+ if (
+ li
+ and len(li.group(2)) <= 3
+ and (
+ (
+ li.group("next_marker")
+ and li.group("marker")[-1] == li.group("next_marker")[-1]
+ )
+ or li.group("next_marker") is None
+ )
+ ):
+ start = li.start()
+ cuddled_list = self._do_lists(graf[start:]).rstrip("\n")
+ if re.match(r"^<(?:ul|ol).*?>", cuddled_list):
+ graf = graf[:start]
+ else:
+ # Not quite a cuddled list. (See not_quite_a_list_cuddled_lists test case)
+ # Store as a simple paragraph.
+ graf = cuddled_list
+ cuddled_list = None
+
+ # Wrap tags.
+ graf = self._run_span_gamut(graf)
+ grafs.append(" " % self._html_class_str_from_tag("p") + graf.lstrip(" \t") + " %s
\[!(?P {contents}\n {contents}\n
)", "
str:
<\?.*?\?> # processing instruction
)
)
- """, re.X)
-
- @mark_stage(Stage.ESCAPE_SPECIAL)
- def _escape_special_chars(self, text: str) -> str:
- # Python markdown note: the HTML tokenization here differs from
- # that in Markdown.pl, hence the behaviour for subtle cases can
- # differ (I believe the tokenizer here does a better job because
- # it isn't susceptible to unmatched '<' and '>' in HTML tags).
- # Note, however, that '>' is not allowed in an auto-link URL
- # here.
- lead_escape_re = re.compile(r'^((?:\\\\)*(?!\\))')
- escaped = []
- is_html_markup = False
- for token in self._sorta_html_tokenize_re.split(text):
- # check token is preceded by 0 or more PAIRS of escapes, because escape pairs
- # escape themselves and don't affect the token
- if is_html_markup and lead_escape_re.match(token):
- # Within tags/HTML-comments/auto-links, encode * and _
- # so they don't conflict with their use in Markdown for
- # italics and strong. We're replacing each such
- # character with its corresponding MD5 checksum value;
- # this is likely overkill, but it should prevent us from
- # colliding with the escape values by accident.
- escape_seq, token = lead_escape_re.split(token)[1:] or ('', token)
- escaped.append(
- escape_seq.replace('\\\\', self._escape_table['\\'])
- + token.replace('*', self._escape_table['*'])
- .replace('_', self._escape_table['_'])
- )
- else:
- escaped.append(self._encode_backslash_escapes(token.replace('\\<', '<')))
- is_html_markup = not is_html_markup
- return ''.join(escaped)
-
- def _is_auto_link(self, text):
- if ':' in text and self._auto_link_re.match(text):
- return True
- elif '@' in text and self._auto_email_link_re.match(text):
- return True
- return False
-
- @mark_stage(Stage.HASH_HTML)
- def _hash_html_spans(self, text: str) -> str:
- # Used for safe_mode.
-
- def _is_code_span(index, token):
- try:
- if token == '':
- peek_tokens = split_tokens[index: index + 3]
- elif token == '':
- peek_tokens = split_tokens[index - 2: index + 1]
- else:
- return False
- except IndexError:
- return False
-
- return re.match(r'md5-[A-Fa-f0-9]{32}', ''.join(peek_tokens))
-
- def _is_comment(token):
- if self.safe_mode == 'replace':
- # don't bother processing each section of comment in replace mode. Just do the whole thing
- return
- return re.match(r'()', token)
-
- tokens = []
- split_tokens = self._sorta_html_tokenize_re.split(text)
- is_html_markup = False
- for index, token in enumerate(split_tokens):
- if is_html_markup and not self._is_auto_link(token) and not _is_code_span(index, token):
- is_comment = _is_comment(token)
- if is_comment:
- tokens.append(self._hash_span(self._sanitize_html(is_comment.group(1))))
- # sanitise but leave comment body intact for further markdown processing
- tokens.append(self._sanitize_html(is_comment.group(2)))
- tokens.append(self._hash_span(self._sanitize_html(is_comment.group(3))))
- else:
- tokens.append(self._hash_span(self._sanitize_html(token)))
- else:
- tokens.append(self._encode_incomplete_tags(token))
- is_html_markup = not is_html_markup
- return ''.join(tokens)
-
- def _unhash_html_spans(self, text: str, spans=True, code=False) -> str:
- '''
- Recursively unhash a block of text
-
- Args:
- spans: unhash anything from `self.html_spans`
- code: unhash code blocks
- '''
- orig = ''
- while text != orig:
- if spans:
- for key, sanitized in list(self.html_spans.items()):
- text = text.replace(key, sanitized)
- if code:
- for code, key in list(self._code_table.items()):
- text = text.replace(key, code)
- orig = text
- return text
-
- def _sanitize_html(self, s: str) -> str:
- if self.safe_mode == "replace":
- return self.html_removed_text
- elif self.safe_mode == "escape":
- replacements = [
- ('&', '&'),
- ('<', '<'),
- ('>', '>'),
- ]
- for before, after in replacements:
- s = s.replace(before, after)
- return s
- else:
- raise MarkdownError("invalid value for 'safe_mode': %r (must be "
- "'escape' or 'replace')" % self.safe_mode)
-
- _inline_link_title = re.compile(r'''
+ """,
+ re.X,
+ )
+
+ @mark_stage(Stage.ESCAPE_SPECIAL)
+ def _escape_special_chars(self, text: str) -> str:
+ # Python markdown note: the HTML tokenization here differs from
+ # that in Markdown.pl, hence the behaviour for subtle cases can
+ # differ (I believe the tokenizer here does a better job because
+ # it isn't susceptible to unmatched '<' and '>' in HTML tags).
+ # Note, however, that '>' is not allowed in an auto-link URL
+ # here.
+ lead_escape_re = re.compile(r"^((?:\\\\)*(?!\\))")
+ escaped = []
+ is_html_markup = False
+ for token in self._sorta_html_tokenize_re.split(text):
+ # check token is preceded by 0 or more PAIRS of escapes, because escape pairs
+ # escape themselves and don't affect the token
+ if is_html_markup and lead_escape_re.match(token):
+ # Within tags/HTML-comments/auto-links, encode * and _
+ # so they don't conflict with their use in Markdown for
+ # italics and strong. We're replacing each such
+ # character with its corresponding MD5 checksum value;
+ # this is likely overkill, but it should prevent us from
+ # colliding with the escape values by accident.
+ escape_seq, token = lead_escape_re.split(token)[1:] or ("", token)
+ escaped.append(
+ escape_seq.replace("\\\\", self._escape_table["\\"])
+ + token.replace("*", self._escape_table["*"]).replace("_", self._escape_table["_"])
+ )
+ else:
+ escaped.append(self._encode_backslash_escapes(token.replace("\\<", "<")))
+ is_html_markup = not is_html_markup
+ return "".join(escaped)
+
+ def _is_auto_link(self, text):
+ if ":" in text and self._auto_link_re.match(text):
+ return True
+ elif "@" in text and self._auto_email_link_re.match(text):
+ return True
+ return False
+
+ @mark_stage(Stage.HASH_HTML)
+ def _hash_html_spans(self, text: str) -> str:
+ # Used for safe_mode.
+
+ def _is_code_span(index, token):
+ try:
+ if token == "":
+ peek_tokens = split_tokens[index : index + 3]
+ elif token == "":
+ peek_tokens = split_tokens[index - 2 : index + 1]
+ else:
+ return False
+ except IndexError:
+ return False
+
+ return re.match(r"md5-[A-Fa-f0-9]{32}", "".join(peek_tokens))
+
+ def _is_comment(token):
+ if self.safe_mode == "replace":
+ # don't bother processing each section of comment in replace mode. Just do the whole thing
+ return
+ return re.match(r"()", token)
+
+ tokens = []
+ split_tokens = self._sorta_html_tokenize_re.split(text)
+ is_html_markup = False
+ for index, token in enumerate(split_tokens):
+ if is_html_markup and not self._is_auto_link(token) and not _is_code_span(index, token):
+ is_comment = _is_comment(token)
+ if is_comment:
+ tokens.append(self._hash_span(self._sanitize_html(is_comment.group(1))))
+ # sanitise but leave comment body intact for further markdown processing
+ tokens.append(self._sanitize_html(is_comment.group(2)))
+ tokens.append(self._hash_span(self._sanitize_html(is_comment.group(3))))
+ else:
+ tokens.append(self._hash_span(self._sanitize_html(token)))
+ else:
+ tokens.append(self._encode_incomplete_tags(token))
+ is_html_markup = not is_html_markup
+ return "".join(tokens)
+
+ def _unhash_html_spans(self, text: str, spans=True, code=False) -> str:
+ """
+ Recursively unhash a block of text
+
+ Args:
+ spans: unhash anything from `self.html_spans`
+ code: unhash code blocks
+ """
+ orig = ""
+ while text != orig:
+ if spans:
+ for key, sanitized in list(self.html_spans.items()):
+ text = text.replace(key, sanitized)
+ if code:
+ for code, key in list(self._code_table.items()):
+ text = text.replace(key, code)
+ orig = text
+ return text
+
+ def _sanitize_html(self, s: str) -> str:
+ if self.safe_mode == "replace":
+ return self.html_removed_text
+ elif self.safe_mode == "escape":
+ replacements = [
+ ("&", "&"),
+ ("<", "<"),
+ (">", ">"),
+ ]
+ for before, after in replacements:
+ s = s.replace(before, after)
+ return s
+ else:
+ raise MarkdownError(
+ "invalid value for 'safe_mode': %r (must be 'escape' or 'replace')" % self.safe_mode
+ )
+
+ _inline_link_title = re.compile(
+ r"""
( # \1
[ \t]+
(['"]) # quote char = \2
@@ -1410,150 +1445,157 @@ def _sanitize_html(self, s: str) -> str:
\2
)? # title is optional
\)$
- ''', re.X | re.S)
- _tail_of_reference_link_re = re.compile(r'''
+ """,
+ re.X | re.S,
+ )
+ _tail_of_reference_link_re = re.compile(
+ r"""
# Match tail of: [text][id]
[ ]? # one optional space
(?:\n[ ]*)? # one optional newline followed by spaces
\[
(?P tags.
-
- This is a combination of Markdown.pl's _DoAnchors() and
- _DoImages(). They are done together because that simplified the
- approach. It was necessary to use a different approach than
- Markdown.pl because of the lack of atomic matching support in
- Python's regex engine used in $g_nested_brackets.
- """
- link_processor = LinkProcessor(self, None)
- if link_processor.test(text):
- text = link_processor.run(text)
- return text
-
- def header_id_from_text(self,
- text: str,
- prefix: str,
- n: Optional[int] = None
- ) -> str:
- """Generate a header id attribute value from the given header
- HTML content.
-
- This is only called if the "header-ids" extra is enabled.
- Subclasses may override this for different header ids.
-
- @param text {str} The text of the header tag
- @param prefix {str} The requested prefix for header ids. This is the
- value of the "header-ids" extra key, if any. Otherwise, None.
- @param n {int} (unused) The
tag.
- @returns {str} The value for the header tag's "id" attribute. Return
- None to not have an id attribute and to exclude this header from
- the TOC (if the "toc" extra is specified).
- """
- header_id = _slugify(text)
- if prefix and isinstance(prefix, str):
- header_id = prefix + '-' + header_id
-
- self._count_from_header_id[header_id] += 1
- if 0 == len(header_id) or self._count_from_header_id[header_id] > 1:
- header_id += '-%s' % self._count_from_header_id[header_id]
-
- return header_id
-
- def _header_id_exists(self, text: str) -> bool:
- header_id = _slugify(text)
- prefix = self.extras['header-ids'].get('prefix')
- if prefix and isinstance(prefix, str):
- header_id = prefix + '-' + header_id
- return header_id in self._count_from_header_id or header_id in map(lambda x: x[1], self._toc)
-
- def _toc_add_entry(self, level: int, id: str, name: str) -> None:
- if level > self._toc_depth:
- return
- if self._toc is None:
- self._toc = []
- self._toc.append((level, id, self._unescape_special_chars(name)))
-
- _h_re_base = r'''
+ """,
+ re.X,
+ )
+
+ def _protect_url(self, url: str) -> str:
+ """
+ Function that passes a URL through `_html_escape_url` to remove any nasty characters,
+ and then hashes the now "safe" URL to prevent other safety mechanisms from tampering
+ with it (eg: escaping "&" in URL parameters)
+ """
+ data_url = self._data_url_re.match(url)
+ charset = None
+ if data_url is not None:
+ mime = data_url.group("mime") or ""
+ if mime.startswith("image/") and data_url.group("token") == ";base64":
+ charset = "base64"
+ url = _html_escape_url(url, safe_mode=self.safe_mode, charset=charset)
+ key = _hash_text(url)
+ self._escape_table[url] = key
+ return key
+
+ _safe_protocols = r"(?:https?|ftp):\/\/|(?:mailto|tel):"
+
+ @property
+ def _safe_href(self):
+ """
+ _safe_href is adapted from pagedown's Markdown.Sanitizer.js
+ From: https://github.com/StackExchange/pagedown/blob/master/LICENSE.txt
+ Original Showdown code copyright (c) 2007 John Fraser
+ Modifications and bugfixes (c) 2009 Dana Robinson
+ Modifications and bugfixes (c) 2009-2014 Stack Exchange Inc.
+ """
+ safe = r"-\w"
+ # omitted ['"<>] for XSS reasons
+ less_safe = r"#/\.!#$%&\(\)\+,/:;=\?@\[\]^`\{\}\|~"
+ # dot seperated hostname, optional port number, not followed by protocol seperator
+ domain = r"(?:[{}]+(?:\.[{}]+)*)(?:(? str:
+ """Turn Markdown link shortcuts into XHTML and
tags.
+
+ This is a combination of Markdown.pl's _DoAnchors() and
+ _DoImages(). They are done together because that simplified the
+ approach. It was necessary to use a different approach than
+ Markdown.pl because of the lack of atomic matching support in
+ Python's regex engine used in $g_nested_brackets.
+ """
+ link_processor = LinkProcessor(self, None)
+ if link_processor.test(text):
+ text = link_processor.run(text)
+ return text
+
+ def header_id_from_text(self, text: str, prefix: str, n: Optional[int] = None) -> str:
+ """Generate a header id attribute value from the given header
+ HTML content.
+
+ This is only called if the "header-ids" extra is enabled.
+ Subclasses may override this for different header ids.
+
+ @param text {str} The text of the header tag
+ @param prefix {str} The requested prefix for header ids. This is the
+ value of the "header-ids" extra key, if any. Otherwise, None.
+ @param n {int} (unused) The
tag.
+ @returns {str} The value for the header tag's "id" attribute. Return
+ None to not have an id attribute and to exclude this header from
+ the TOC (if the "toc" extra is specified).
+ """
+ header_id = _slugify(text)
+ if prefix and isinstance(prefix, str):
+ header_id = prefix + "-" + header_id
+
+ self._count_from_header_id[header_id] += 1
+ if 0 == len(header_id) or self._count_from_header_id[header_id] > 1:
+ header_id += "-%s" % self._count_from_header_id[header_id]
+
+ return header_id
+
+ def _header_id_exists(self, text: str) -> bool:
+ header_id = _slugify(text)
+ prefix = self.extras["header-ids"].get("prefix")
+ if prefix and isinstance(prefix, str):
+ header_id = prefix + "-" + header_id
+ return header_id in self._count_from_header_id or header_id in map(lambda x: x[1], self._toc)
+
+ def _toc_add_entry(self, level: int, id: str, name: str) -> None:
+ if level > self._toc_depth:
+ return
+ if self._toc is None:
+ self._toc = []
+ self._toc.append((level, id, self._unescape_special_chars(name)))
+
+ _h_re_base = r"""
(^(.+)[ \t]{0,99}\n(=+|-+)[ \t]*\n+)
|
(^(\#{1,6}) # \1 = string of #'s
@@ -1564,127 +1606,131 @@ def _toc_add_entry(self, level: int, id: str, name: str) -> None:
\#* # optional closing #'s (not counted)
\n+
)
- '''
-
- _h_re = re.compile(_h_re_base % '*', re.X | re.M)
- _h_re_tag_friendly = re.compile(_h_re_base % '+', re.X | re.M)
-
- def _h_sub(self, match: re.Match) -> str:
- '''Handles processing markdown headers'''
- if match.group(1) is not None and match.group(3) == "-":
- return match.group(1)
- elif match.group(1) is not None:
- # Setext header
- n = {"=": 1, "-": 2}[match.group(3)[0]]
- header_group = match.group(2)
- else:
- # atx header
- n = len(match.group(5))
- header_group = match.group(6)
-
- demote_headers = self.extras.get("demote-headers")
- if demote_headers:
- n = min(n + demote_headers, 6)
- header_id_attr = ""
- if "header-ids" in self.extras:
- header_id = self.header_id_from_text(header_group,
- self.extras["header-ids"].get('prefix'), n)
- if header_id:
- header_id_attr = ' id="%s"' % header_id
- html = self._run_span_gamut(header_group)
- if "toc" in self.extras and header_id:
- self._toc_add_entry(n, header_id, html)
- return "
tags.
- """
- yield 0, ""
- yield from inner
- yield 0, ""
-
- def _add_newline(self, inner):
- # Add newlines around the inner contents so that _strict_tag_block_re matches the outer div.
- yield 0, "\n"
- yield from inner
- yield 0, "\n"
-
- def wrap(self, source, outfile=None):
- """Return the source with a code, pre, and div."""
- if outfile is None:
- # pygments >= 2.12
- return self._add_newline(self._wrap_pre(self._wrap_code(source)))
- else:
- # pygments < 2.12
- return self._wrap_div(self._add_newline(self._wrap_pre(self._wrap_code(source))))
-
- formatter_opts.setdefault("cssclass", "codehilite")
- formatter = HtmlCodeFormatter(**formatter_opts)
- return pygments.highlight(codeblock, lexer, formatter)
-
- def _code_block_sub(self, match: re.Match) -> str:
- codeblock = match.group(1)
- codeblock = self._outdent(codeblock)
- codeblock = self._detab(codeblock)
- codeblock = codeblock.lstrip('\n') # trim leading newlines
- codeblock = codeblock.rstrip() # trim trailing whitespace
-
- pre_class_str = self._html_class_str_from_tag("pre")
- code_class_str = self._html_class_str_from_tag("code")
-
- codeblock = self._encode_code(codeblock)
-
- return "\n
\n".format(
- pre_class_str, code_class_str, codeblock)
-
- def _html_class_str_from_tag(self, tag: str) -> str:
- """Get the appropriate ' class="..."' string (note the leading
- space), if any, for the given tag.
- """
- if "html-classes" not in self.extras:
- return ""
- try:
- html_classes_from_tag = self.extras["html-classes"]
- except TypeError:
- return ""
- else:
- if isinstance(html_classes_from_tag, dict):
- if tag in html_classes_from_tag:
- return ' class="%s"' % html_classes_from_tag[tag]
- return ""
-
- @mark_stage(Stage.CODE_BLOCKS)
- def _do_code_blocks(self, text: str) -> str:
- """Process Markdown `{}\n` blocks."""
- code_block_re = re.compile(r'''
+ """,
+ re.M | re.X | re.S,
+ )
+
+ _task_list_warpper_str = r' %s'
+
+ def _task_list_item_sub(self, match: re.Match) -> str:
+ marker = match.group(1)
+ item_text = match.group(2)
+ if marker in ["[x]", "[X]"]:
+ return self._task_list_warpper_str % ("checked ", item_text)
+ elif marker == "[ ]":
+ return self._task_list_warpper_str % ("", item_text)
+ # returning None has same effect as returning empty str, but only
+ # one makes the type checker happy
+ return ""
+
+ _last_li_endswith_two_eols = False
+
+ def _list_item_sub(self, match: re.Match) -> str:
+ item = match.group(4)
+ leading_line = match.group(1)
+ if leading_line or "\n\n" in item or self._last_li_endswith_two_eols:
+ item = self._uniform_outdent(item, min_outdent=" ", max_outdent=self.tab)[1]
+ item = self._run_block_gamut(item)
+ else:
+ # Recursion for sub-lists:
+ item = self._do_lists(self._uniform_outdent(item, min_outdent=" ")[1])
+ if item.endswith("\n"):
+ item = item[:-1]
+ item = self._run_span_gamut(item)
+ self._last_li_endswith_two_eols = len(match.group(5)) == 2
+
+ if "task_list" in self.extras:
+ item = self._task_list_item_re.sub(self._task_list_item_sub, item)
+
+ return " tags.
+ """
+ yield 0, ""
+ yield from inner
+ yield 0, ""
+
+ def _add_newline(self, inner):
+ # Add newlines around the inner contents so that _strict_tag_block_re matches the outer div.
+ yield 0, "\n"
+ yield from inner
+ yield 0, "\n"
+
+ def wrap(self, source, outfile=None):
+ """Return the source with a code, pre, and div."""
+ if outfile is None:
+ # pygments >= 2.12
+ return self._add_newline(self._wrap_pre(self._wrap_code(source)))
+ else:
+ # pygments < 2.12
+ return self._wrap_div(self._add_newline(self._wrap_pre(self._wrap_code(source))))
+
+ formatter_opts.setdefault("cssclass", "codehilite")
+ formatter = HtmlCodeFormatter(**formatter_opts)
+ return pygments.highlight(codeblock, lexer, formatter)
+
+ def _code_block_sub(self, match: re.Match) -> str:
+ codeblock = match.group(1)
+ codeblock = self._outdent(codeblock)
+ codeblock = self._detab(codeblock)
+ codeblock = codeblock.lstrip("\n") # trim leading newlines
+ codeblock = codeblock.rstrip() # trim trailing whitespace
+
+ pre_class_str = self._html_class_str_from_tag("pre")
+ code_class_str = self._html_class_str_from_tag("code")
+
+ codeblock = self._encode_code(codeblock)
+
+ return "\n
\n".format(pre_class_str, code_class_str, codeblock)
+
+ def _html_class_str_from_tag(self, tag: str) -> str:
+ """Get the appropriate ' class="..."' string (note the leading
+ space), if any, for the given tag.
+ """
+ if "html-classes" not in self.extras:
+ return ""
+ try:
+ html_classes_from_tag = self.extras["html-classes"]
+ except TypeError:
+ return ""
+ else:
+ if isinstance(html_classes_from_tag, dict):
+ if tag in html_classes_from_tag:
+ return ' class="%s"' % html_classes_from_tag[tag]
+ return ""
+
+ @mark_stage(Stage.CODE_BLOCKS)
+ def _do_code_blocks(self, text: str) -> str:
+ """Process Markdown `{}\n` blocks."""
+ code_block_re = re.compile(
+ r"""
(?:\n\n|\A\n?)
( # $1 = the code block -- one or more lines, starting with a space/tab
(?:
@@ -1916,19 +1962,22 @@ def _do_code_blocks(self, text: str) -> str:
# Lookahead to make sure this block isn't already in a code block.
# Needed when syntax highlighting is being used.
(?!([^<]|<(/?)span)*\)
- ''' % (self.tab_width, self.tab_width),
- re.M | re.X)
- return code_block_re.sub(self._code_block_sub, text)
-
- # Rules for a code span:
- # - backslash escapes are not interpreted in a code span
- # - to include one or or a run of more backticks the delimiters must
- # be a longer run of backticks
- # - cannot start or end a code span with a backtick; pad with a
- # space and that space will be removed in the emitted HTML
- # See `test/tm-cases/escapes.text` for a number of edge-case
- # examples.
- _code_span_re = re.compile(r'''
+ """
+ % (self.tab_width, self.tab_width),
+ re.M | re.X,
+ )
+ return code_block_re.sub(self._code_block_sub, text)
+
+ # Rules for a code span:
+ # - backslash escapes are not interpreted in a code span
+ # - to include one or or a run of more backticks the delimiters must
+ # be a longer run of backticks
+ # - cannot start or end a code span with a backtick; pad with a
+ # space and that space will be removed in the emitted HTML
+ # See `test/tm-cases/escapes.text` for a number of edge-case
+ # examples.
+ _code_span_re = re.compile(
+ r"""
(? str:
(? str:
- c = match.group(2).strip(" \t")
- c = self._encode_code(c)
- return "{}".format(self._html_class_str_from_tag("code"), c)
-
- @mark_stage(Stage.CODE_SPANS)
- def _do_code_spans(self, text: str) -> str:
- # * Backtick quotes are used for spans.
- #
- # * You can use multiple backticks as the delimiters if you want to
- # include literal backticks in the code span. So, this input:
- #
- # Just type ``foo `bar` baz`` at the prompt.
- #
- # Will translate to:
- #
- # foo `bar` baz at the prompt.`bar` ...
- return self._code_span_re.sub(self._code_span_sub, text)
-
- def _encode_code(self, text: str) -> str:
- """Encode/escape certain characters inside Markdown code runs.
- The point is that in code, these characters are literals,
- and lose their special Markdown meanings.
- """
- replacements = [
- # Encode all ampersands; HTML entities are not
- # entities within a Markdown code span.
- ('&', '&'),
- # Do the angle bracket song and dance:
- ('<', '<'),
- ('>', '>'),
- ]
- for before, after in replacements:
- text = text.replace(before, after)
- hashed = _hash_text(text)
- self._code_table[text] = hashed
- return hashed
-
- _strong_re = re.compile(r"(\*\*|__)(?=\S)(.+?[*_]?)(?<=\S)\1", re.S)
- _em_re = re.compile(r"(\*|_)(?=\S)(.*?\S)\1", re.S)
-
- @mark_stage(Stage.ITALIC_AND_BOLD)
- def _do_italics_and_bold(self, text: str) -> str:
- # must go first:
- text = self._strong_re.sub(r"\2", text)
- text = self._em_re.sub(r"\2", text)
- return text
-
- _block_quote_base = r'''
+ """,
+ re.X | re.S,
+ )
+
+ def _code_span_sub(self, match: re.Match) -> str:
+ c = match.group(2).strip(" \t")
+ c = self._encode_code(c)
+ return "{}".format(self._html_class_str_from_tag("code"), c)
+
+ @mark_stage(Stage.CODE_SPANS)
+ def _do_code_spans(self, text: str) -> str:
+ # * Backtick quotes are used for spans.
+ #
+ # * You can use multiple backticks as the delimiters if you want to
+ # include literal backticks in the code span. So, this input:
+ #
+ # Just type ``foo `bar` baz`` at the prompt.
+ #
+ # Will translate to:
+ #
+ # foo `bar` baz at the prompt.`bar` ...
+ return self._code_span_re.sub(self._code_span_sub, text)
+
+ def _encode_code(self, text: str) -> str:
+ """Encode/escape certain characters inside Markdown code runs.
+ The point is that in code, these characters are literals,
+ and lose their special Markdown meanings.
+ """
+ replacements = [
+ # Encode all ampersands; HTML entities are not
+ # entities within a Markdown code span.
+ ("&", "&"),
+ # Do the angle bracket song and dance:
+ ("<", "<"),
+ (">", ">"),
+ ]
+ for before, after in replacements:
+ text = text.replace(before, after)
+ hashed = _hash_text(text)
+ self._code_table[text] = hashed
+ return hashed
+
+ _strong_re = re.compile(r"(\*\*|__)(?=\S)(.+?[*_]?)(?<=\S)\1", re.S)
+ _em_re = re.compile(r"(\*|_)(?=\S)(.*?\S)\1", re.S)
+
+ @mark_stage(Stage.ITALIC_AND_BOLD)
+ def _do_italics_and_bold(self, text: str) -> str:
+ # must go first:
+ text = self._strong_re.sub(r"\2", text)
+ text = self._em_re.sub(r"\2", text)
+ return text
+
+ _block_quote_base = r"""
( # Wrap whole match in \1
(
^[ \t]*>%s[ \t]? # '>' at the start of a line
@@ -2006,184 +2057,195 @@ def _do_italics_and_bold(self, text: str) -> str:
(.+\n)* # subsequent consecutive lines
)+
)
- '''
- _block_quote_re = re.compile(_block_quote_base % '', re.M | re.X)
- _block_quote_re_spoiler = re.compile(_block_quote_base % '[ \t]*?!?', re.M | re.X)
- _bq_one_level_re = re.compile('^[ \t]*>[ \t]?', re.M)
- _bq_one_level_re_spoiler = re.compile('^[ \t]*>[ \t]*?![ \t]?', re.M)
- _bq_all_lines_spoilers = re.compile(r'\A(?:^[ \t]*>[ \t]*?!.*[\n\r]*)+\Z', re.M)
- _html_pre_block_re = re.compile(r'(\s*.+?
)', re.S)
- def _dedent_two_spaces_sub(self, match: re.Match) -> str:
- return re.sub(r'(?m)^ ', '', match.group(1))
-
- def _block_quote_sub(self, match: re.Match) -> str:
- bq = match.group(1)
- is_spoiler = 'spoiler' in self.extras and self._bq_all_lines_spoilers.match(bq)
- # trim one level of quoting
- if is_spoiler:
- bq = self._bq_one_level_re_spoiler.sub('', bq)
- else:
- bq = self._bq_one_level_re.sub('', bq)
- # trim whitespace-only lines
- bq = self._ws_only_line_re.sub('', bq)
- bq = self._run_block_gamut(bq) # recurse
-
- bq = re.sub('(?m)^', ' ', bq)
- # These leading spaces screw with content, so we need to fix that:
- bq = self._html_pre_block_re.sub(self._dedent_two_spaces_sub, bq)
-
- if is_spoiler:
- return '
\n%s\n
\n\n' % bq
- else:
- return '\n%s\n
\n\n' % bq
-
- @mark_stage(Stage.BLOCK_QUOTES)
- def _do_block_quotes(self, text: str) -> str:
- if '>' not in text:
- return text
- if 'spoiler' in self.extras:
- return self._block_quote_re_spoiler.sub(self._block_quote_sub, text)
- else:
- return self._block_quote_re.sub(self._block_quote_sub, text)
-
- @mark_stage(Stage.PARAGRAPHS)
- def _form_paragraphs(self, text: str) -> str:
- # Strip leading and trailing lines:
- text = text.strip('\n')
-
- # Wrap
',
- ]
-
- if not self.footnote_title:
- self.footnote_title = "Jump back to footnote %d in the text."
- if not self.footnote_return_symbol:
- self.footnote_return_symbol = "↩"
-
- # self.footnotes is generated in _strip_footnote_definitions, which runs re.sub on the whole
- # text. This means that the dict keys are inserted in order of appearance. Use the dict to
- # sort footnote ids by that same order
- self.footnote_ids.sort(key=lambda a: list(self.footnotes.keys()).index(a))
- for i, id in enumerate(self.footnote_ids):
- if i != 0:
- footer.append('')
- footer.append('.+?
)", re.S)
+
+ def _dedent_two_spaces_sub(self, match: re.Match) -> str:
+ return re.sub(r"(?m)^ ", "", match.group(1))
+
+ def _block_quote_sub(self, match: re.Match) -> str:
+ bq = match.group(1)
+ is_spoiler = "spoiler" in self.extras and self._bq_all_lines_spoilers.match(bq)
+ # trim one level of quoting
+ if is_spoiler:
+ bq = self._bq_one_level_re_spoiler.sub("", bq)
+ else:
+ bq = self._bq_one_level_re.sub("", bq)
+ # trim whitespace-only lines
+ bq = self._ws_only_line_re.sub("", bq)
+ bq = self._run_block_gamut(bq) # recurse
+
+ bq = re.sub("(?m)^", " ", bq)
+ # These leading spaces screw with content, so we need to fix that:
+ bq = self._html_pre_block_re.sub(self._dedent_two_spaces_sub, bq)
+
+ if is_spoiler:
+ return '
", re.DOTALL) # Wraped in \n%s\n
\n\n' % bq
+ else:
+ return "\n%s\n
\n\n" % bq
+
+ @mark_stage(Stage.BLOCK_QUOTES)
+ def _do_block_quotes(self, text: str) -> str:
+ if ">" not in text:
+ return text
+ if "spoiler" in self.extras:
+ return self._block_quote_re_spoiler.sub(self._block_quote_sub, text)
+ else:
+ return self._block_quote_re.sub(self._block_quote_sub, text)
+
+ @mark_stage(Stage.PARAGRAPHS)
+ def _form_paragraphs(self, text: str) -> str:
+ # Strip leading and trailing lines:
+ text = text.strip("\n")
+
+ # Wrap
",
+ ]
+
+ if not self.footnote_title:
+ self.footnote_title = "Jump back to footnote %d in the text."
+ if not self.footnote_return_symbol:
+ self.footnote_return_symbol = "↩"
+
+ # self.footnotes is generated in _strip_footnote_definitions, which runs re.sub on the whole
+ # text. This means that the dict keys are inserted in order of appearance. Use the dict to
+ # sort footnote ids by that same order
+ self.footnote_ids.sort(key=lambda a: list(self.footnotes.keys()).index(a))
+ for i, id in enumerate(self.footnote_ids):
+ if i != 0:
+ footer.append("")
+ footer.append('` tag
-
- Args:
- url: the image URL/src
- title_attr: a string containing the title attribute of the tag (eg: `' title="..."'`)
- link_text: the human readable text portion of the link
-
- Returns:
- A tuple containing:
-
- 1. The HTML string
- 2. The length of the opening HTML tag in the string. For `
` it's the whole string.
- This section will be skipped by the link processor
- '''
- img_class_str = self.md._html_class_str_from_tag("img")
- result = (
- f'
Tuple[str, int]:
- '''
- Takes a URL, title and link text and returns an HTML `` tag
-
- Args:
- url: the URL
- title_attr: a string containing the title attribute of the tag (eg: `' title="..."'`)
- link_text: the human readable text portion of the link
-
- Returns:
- A tuple containing:
-
- 1. The HTML string
- 2. The length of the opening HTML tag in the string. This section will be skipped
- by the link processor
- '''
- if self.md.safe_mode and not self.md._safe_href.match(url):
- result_head = f''
- else:
- result_head = f''
-
- return f'{result_head}{link_text}', len(result_head)
-
- def run(self, text: str):
- MAX_LINK_TEXT_SENTINEL = 3000 # markdown2 issue 24
-
- # `anchor_allowed_pos` is used to support img links inside
- # anchors, but not anchors inside anchors. An anchor's start
- # pos must be `>= anchor_allowed_pos`.
- anchor_allowed_pos = 0
-
- curr_pos = 0
-
- while True:
- # The next '[' is the start of:
- # - an inline anchor: [text](url "title")
- # - a reference anchor: [text][id]
- # - an inline img: 
- # - a reference img: ![text][id]
- # - a footnote ref: [^id]
- # (Only if 'footnotes' extra enabled)
- # - a footnote defn: [^id]: ...
- # (Only if 'footnotes' extra enabled) These have already
- # been stripped in _strip_footnote_definitions() so no
- # need to watch for them.
- # - a link definition: [id]: url "title"
- # These have already been stripped in
- # _strip_link_definitions() so no need to watch for them.
- # - not markup: [...anything else...
- try:
- start_idx = text.index('[', curr_pos)
- except ValueError:
- break
- text_length = len(text)
-
- # Find the matching closing ']'.
- # Markdown.pl allows *matching* brackets in link text so we
- # will here too. Markdown.pl *doesn't* currently allow
- # matching brackets in img alt text -- we'll differ in that
- # regard.
- bracket_depth = 0
-
- for p in range(
- start_idx + 1,
- min(start_idx + MAX_LINK_TEXT_SENTINEL, text_length)
- ):
- ch = text[p]
- if ch == ']':
- bracket_depth -= 1
- if bracket_depth < 0:
- break
- elif ch == '[':
- bracket_depth += 1
- else:
- # Closing bracket not found within sentinel length.
- # This isn't markup.
- curr_pos = start_idx + 1
- continue
- link_text = text[start_idx + 1: p]
-
- # Fix for issue 341 - Injecting XSS into link text
- if self.md.safe_mode:
- link_text = self.md._hash_html_spans(link_text)
- link_text = self.md._unhash_html_spans(link_text)
-
- # Possibly a footnote ref?
- if "footnotes" in self.md.extras and link_text.startswith("^"):
- normed_id = re.sub(r'\W', '-', link_text[1:])
- if normed_id in self.md.footnotes:
- result = (
- f''
- # insert special footnote marker that's easy to find and match against later
- f'{self.md._footnote_marker}-{normed_id}'
- )
- text = text[:start_idx] + result + text[p+1:]
- else:
- # This id isn't defined, leave the markup alone.
- curr_pos = p + 1
- continue
-
- # Now determine what this is by the remainder.
- p += 1
-
- # -- Extract the URL, title and end index from the link
-
- # inline anchor or inline img
- if text[p:p + 1] == '(':
- if not self.options.get('inline', True):
- curr_pos = start_idx + 1
- continue
-
- parsed = self.parse_inline_anchor_or_image(text, link_text, p)
- if not parsed:
- # text isn't markup
- curr_pos = start_idx + 1
- continue
-
- text, url, title, url_end_idx = parsed
- url = self.md._unhash_html_spans(url, code=True)
- # reference anchor or reference img
- else:
- if not self.options.get('ref', True):
- curr_pos = start_idx + 1
- continue
-
- parsed = self.parse_ref_anchor_or_ref_image(text, link_text, p)
- if not parsed:
- curr_pos = start_idx + 1
- continue
-
- text, url, title, url_end_idx = parsed
- if url is None:
- # This id isn't defined, leave the markup alone.
- # set current pos to end of link title and continue from there
- curr_pos = p
- continue
-
- # -- Encode and hash the URL and title to avoid conflicts with italics/bold
-
- url = (
- url
- .replace('*', self.md._escape_table['*'])
- .replace('_', self.md._escape_table['_'])
- )
- if title:
- title = (
- _xml_escape_attr(title)
- .replace('*', self.md._escape_table['*'])
- .replace('_', self.md._escape_table['_'])
- )
- title_str = f' title="{title}"'
- else:
- title_str = ''
-
- # -- Process the anchor/image
-
- is_img = start_idx > 0 and text[start_idx-1] == "!"
- if is_img:
- if 'img' not in self.options.get('tags', ['img']):
- curr_pos = start_idx + 1
- continue
-
- start_idx -= 1
- result, skip = self.process_image(url, title_str, link_text)
- elif start_idx >= anchor_allowed_pos:
- if 'a' not in self.options.get('tags', ['a']):
- curr_pos = start_idx + 1
- continue
-
- result, skip = self.process_anchor(url, title_str, link_text)
- else:
- # anchor not allowed here/invalid markup
- curr_pos = start_idx + 1
- continue
-
- if "smarty-pants" in self.md.extras:
- result = result.replace('"', self.md._escape_table['"'])
-
- #
allowed from curr_pos onwards, allowed from anchor_allowed_pos onwards.
- # this means images can exist within `` tags but anchors can only come after the
- # current anchor has been closed
- curr_pos = start_idx + skip
- anchor_allowed_pos = start_idx + len(result)
- text = text[:start_idx] + result + text[url_end_idx:]
- return text
-
- def test(self, text):
- return '(' in text or '[' in text
+class LinkProcessor(Extra):
+ name = "link-processor"
+ order = (Stage.ITALIC_AND_BOLD,), (Stage.ESCAPE_SPECIAL,)
+ options: _LinkProcessorExtraOpts
+
+ def __init__(self, md: Markdown, options: Optional[dict]):
+ options = options or {}
+ super().__init__(md, options)
+
+ def parse_inline_anchor_or_image(
+ self, text: str, _link_text: str, start_idx: int
+ ) -> Optional[Tuple[str, str, Optional[str], int]]:
+ """
+ Parse a string and extract a link from it. This can be an inline anchor or an image.
+
+ Args:
+ text: the whole text containing the link
+ link_text: the human readable text inside the link
+ start_idx: the index of the link within `text`
+
+ Returns:
+ None if a link was not able to be parsed from `text`.
+ If successful, a tuple is returned containing:
+
+ 1. potentially modified version of the `text` param
+ 2. the URL
+ 3. the title (can be None if not present)
+ 4. the index where the link ends within text
+ """
+ idx = self.md._find_non_whitespace(text, start_idx + 1)
+ if idx == len(text):
+ return
+ end_idx = idx
+ has_anglebrackets = text[idx] == "<"
+ if has_anglebrackets:
+ end_idx = self.md._find_balanced(text, end_idx + 1, "<", ">")
+ end_idx = self.md._find_balanced(text, end_idx, "(", ")")
+ match = self.md._inline_link_title.search(text, idx, end_idx)
+ if not match:
+ return
+ url, title = text[idx : match.start()], match.group("title")
+ if has_anglebrackets:
+ url = self.md._strip_anglebrackets.sub(r"\1", url)
+ return text, url, title, end_idx
+
+ def process_link_shortrefs(
+ self, text: str, link_text: str, start_idx: int
+ ) -> Tuple[Optional[re.Match], str]:
+ """
+ Detects shortref links within a string and converts them to normal references
+
+ Args:
+ text: the whole text containing the link
+ link_text: the human readable text inside the link
+ start_idx: the index of the link within `text`
+
+ Returns:
+ A tuple containing:
+
+ 1. A potential `re.Match` against the link reference within `text` (will be None if not found)
+ 2. potentially modified version of the `text` param
+ """
+ match = None
+ # check if there's no tailing id section
+ if link_text and re.match(r"[ ]?(?:\n[ ]*)?(?!\[)", text[start_idx:]):
+ # try a match with `[]` inserted into the text
+ match = self.md._tail_of_reference_link_re.match(
+ f"{text[:start_idx]}[]{text[start_idx:]}", start_idx
+ )
+ if match:
+ # if we get a match, we'll have to modify the `text` variable to insert the `[]`
+ # but we ONLY want to do that if the link_id is valid. This makes sure that we
+ # don't get stuck in any loops and also that when a user inputs `[abc]` we don't
+ # output `[abc][]` in the final HTML
+ if (match.group("id").lower() or link_text.lower()) in self.md.urls:
+ text = f"{text[:start_idx]}[]{text[start_idx:]}"
+ else:
+ match = None
+
+ return match, text
+
+ def parse_ref_anchor_or_ref_image(
+ self, text: str, link_text: str, start_idx: int
+ ) -> Optional[Tuple[str, Optional[str], Optional[str], int]]:
+ """
+ Parse a string and extract a link from it. This can be a reference anchor or image.
+
+ Args:
+ text: the whole text containing the link
+ link_text: the human readable text inside the link
+ start_idx: the index of the link within `text`
+
+ Returns:
+ None if a link was not able to be parsed from `text`.
+ If successful, a tuple is returned containing:
+
+ 1. potentially modified version of the `text` param
+ 2. the URL (can be None if the reference doesn't exist)
+ 3. the title (can be None if not present)
+ 4. the index where the link ends within text
+ """
+ match = None
+ if "link-shortrefs" in self.md.extras:
+ match, text = self.process_link_shortrefs(text, link_text, start_idx)
+
+ match = match or self.md._tail_of_reference_link_re.match(text, start_idx)
+ if not match:
+ # text isn't markup
+ return
+
+ link_id = match.group("id").lower() or link_text.lower() # for links like [this][]
+
+ url = self.md.urls.get(link_id)
+ title = self.md.titles.get(link_id)
+ url_end_idx = match.end()
+
+ return text, url, title, url_end_idx
+
+ def process_image(self, url: str, title_attr: str, link_text: str) -> Tuple[str, int]:
+ """
+ Takes a URL, title and link text and returns an HTML `
` tag
+
+ Args:
+ url: the image URL/src
+ title_attr: a string containing the title attribute of the tag (eg: `' title="..."'`)
+ link_text: the human readable text portion of the link
+
+ Returns:
+ A tuple containing:
+
+ 1. The HTML string
+ 2. The length of the opening HTML tag in the string. For `
` it's the whole string.
+ This section will be skipped by the link processor
+ """
+ img_class_str = self.md._html_class_str_from_tag("img")
+ result = (
+ f'
Tuple[str, int]:
+ """
+ Takes a URL, title and link text and returns an HTML `` tag
+
+ Args:
+ url: the URL
+ title_attr: a string containing the title attribute of the tag (eg: `' title="..."'`)
+ link_text: the human readable text portion of the link
+
+ Returns:
+ A tuple containing:
+
+ 1. The HTML string
+ 2. The length of the opening HTML tag in the string. This section will be skipped
+ by the link processor
+ """
+ if self.md.safe_mode and not self.md._safe_href.match(url):
+ result_head = f''
+ else:
+ result_head = f''
+
+ return f"{result_head}{link_text}", len(result_head)
+
+ def run(self, text: str):
+ MAX_LINK_TEXT_SENTINEL = 3000 # markdown2 issue 24
+
+ # `anchor_allowed_pos` is used to support img links inside
+ # anchors, but not anchors inside anchors. An anchor's start
+ # pos must be `>= anchor_allowed_pos`.
+ anchor_allowed_pos = 0
+
+ curr_pos = 0
+
+ while True:
+ # The next '[' is the start of:
+ # - an inline anchor: [text](url "title")
+ # - a reference anchor: [text][id]
+ # - an inline img: 
+ # - a reference img: ![text][id]
+ # - a footnote ref: [^id]
+ # (Only if 'footnotes' extra enabled)
+ # - a footnote defn: [^id]: ...
+ # (Only if 'footnotes' extra enabled) These have already
+ # been stripped in _strip_footnote_definitions() so no
+ # need to watch for them.
+ # - a link definition: [id]: url "title"
+ # These have already been stripped in
+ # _strip_link_definitions() so no need to watch for them.
+ # - not markup: [...anything else...
+ try:
+ start_idx = text.index("[", curr_pos)
+ except ValueError:
+ break
+ text_length = len(text)
+
+ # Find the matching closing ']'.
+ # Markdown.pl allows *matching* brackets in link text so we
+ # will here too. Markdown.pl *doesn't* currently allow
+ # matching brackets in img alt text -- we'll differ in that
+ # regard.
+ bracket_depth = 0
+
+ for p in range(start_idx + 1, min(start_idx + MAX_LINK_TEXT_SENTINEL, text_length)):
+ ch = text[p]
+ if ch == "]":
+ bracket_depth -= 1
+ if bracket_depth < 0:
+ break
+ elif ch == "[":
+ bracket_depth += 1
+ else:
+ # Closing bracket not found within sentinel length.
+ # This isn't markup.
+ curr_pos = start_idx + 1
+ continue
+ link_text = text[start_idx + 1 : p]
+
+ # Fix for issue 341 - Injecting XSS into link text
+ if self.md.safe_mode:
+ link_text = self.md._hash_html_spans(link_text)
+ link_text = self.md._unhash_html_spans(link_text)
+
+ # Possibly a footnote ref?
+ if "footnotes" in self.md.extras and link_text.startswith("^"):
+ normed_id = re.sub(r"\W", "-", link_text[1:])
+ if normed_id in self.md.footnotes:
+ result = (
+ f''
+ # insert special footnote marker that's easy to find and match against later
+ f'{self.md._footnote_marker}-{normed_id}'
+ )
+ text = text[:start_idx] + result + text[p + 1 :]
+ else:
+ # This id isn't defined, leave the markup alone.
+ curr_pos = p + 1
+ continue
+
+ # Now determine what this is by the remainder.
+ p += 1
+
+ # -- Extract the URL, title and end index from the link
+
+ # inline anchor or inline img
+ if text[p : p + 1] == "(":
+ if not self.options.get("inline", True):
+ curr_pos = start_idx + 1
+ continue
+
+ parsed = self.parse_inline_anchor_or_image(text, link_text, p)
+ if not parsed:
+ # text isn't markup
+ curr_pos = start_idx + 1
+ continue
+
+ text, url, title, url_end_idx = parsed
+ url = self.md._unhash_html_spans(url, code=True)
+ # reference anchor or reference img
+ else:
+ if not self.options.get("ref", True):
+ curr_pos = start_idx + 1
+ continue
+
+ parsed = self.parse_ref_anchor_or_ref_image(text, link_text, p)
+ if not parsed:
+ curr_pos = start_idx + 1
+ continue
+
+ text, url, title, url_end_idx = parsed
+ if url is None:
+ # This id isn't defined, leave the markup alone.
+ # set current pos to end of link title and continue from there
+ curr_pos = p
+ continue
+
+ # -- Encode and hash the URL and title to avoid conflicts with italics/bold
+
+ url = url.replace("*", self.md._escape_table["*"]).replace("_", self.md._escape_table["_"])
+ if title:
+ title = (
+ _xml_escape_attr(title)
+ .replace("*", self.md._escape_table["*"])
+ .replace("_", self.md._escape_table["_"])
+ )
+ title_str = f' title="{title}"'
+ else:
+ title_str = ""
+
+ # -- Process the anchor/image
+
+ is_img = start_idx > 0 and text[start_idx - 1] == "!"
+ if is_img:
+ if "img" not in self.options.get("tags", ["img"]):
+ curr_pos = start_idx + 1
+ continue
+
+ start_idx -= 1
+ result, skip = self.process_image(url, title_str, link_text)
+ elif start_idx >= anchor_allowed_pos:
+ if "a" not in self.options.get("tags", ["a"]):
+ curr_pos = start_idx + 1
+ continue
+
+ result, skip = self.process_anchor(url, title_str, link_text)
+ else:
+ # anchor not allowed here/invalid markup
+ curr_pos = start_idx + 1
+ continue
+
+ if "smarty-pants" in self.md.extras:
+ result = result.replace('"', self.md._escape_table['"'])
+
+ #
allowed from curr_pos onwards, allowed from anchor_allowed_pos onwards.
+ # this means images can exist within `` tags but anchors can only come after the
+ # current anchor has been closed
+ curr_pos = start_idx + skip
+ anchor_allowed_pos = start_idx + len(result)
+ text = text[:start_idx] + result + text[url_end_idx:]
+
+ return text
+
+ def test(self, text):
+ return "(" in text or "[" in text
# User facing extras
@@ -2834,486 +2895,491 @@ def test(self, text):
class Admonitions(Extra):
- '''
- Enable parsing of RST admonitions
- '''
+ """
+ Enable parsing of RST admonitions
+ """
- name = 'admonitions'
- order = (Stage.BLOCK_GAMUT, Stage.LINK_DEFS), ()
+ name = "admonitions"
+ order = (Stage.BLOCK_GAMUT, Stage.LINK_DEFS), ()
- admonitions = r'admonition|attention|caution|danger|error|hint|important|note|tip|warning'
+ admonitions = r"admonition|attention|caution|danger|error|hint|important|note|tip|warning"
- admonitions_re = re.compile(r'''
+ admonitions_re = re.compile(
+ r"""
^(\ *)\.\.\ (%s)::\ * # $1 leading indent, $2 the admonition
(.*)? # $3 admonition title
((?:\s*\n\1\ {3,}.*)+?) # $4 admonition body (required)
(?=\s*(?:\Z|\n{4,}|\n\1?\ {0,2}\S)) # until EOF, 3 blank lines or something less indented
- ''' % admonitions,
- re.IGNORECASE | re.MULTILINE | re.VERBOSE
- )
+ """
+ % admonitions,
+ re.IGNORECASE | re.MULTILINE | re.VERBOSE,
+ )
- def test(self, text):
- return self.admonitions_re.search(text) is not None
+ def test(self, text):
+ return self.admonitions_re.search(text) is not None
- def sub(self, match: re.Match) -> str:
- lead_indent, admonition_name, title, body = match.groups()
+ def sub(self, match: re.Match) -> str:
+ lead_indent, admonition_name, title, body = match.groups()
- admonition_type = '%s' % admonition_name
+ admonition_type = "%s" % admonition_name
- # figure out the class names to assign the block
- if admonition_name.lower() == 'admonition':
- admonition_class = 'admonition'
- else:
- admonition_class = 'admonition %s' % admonition_name.lower()
+ # figure out the class names to assign the block
+ if admonition_name.lower() == "admonition":
+ admonition_class = "admonition"
+ else:
+ admonition_class = "admonition %s" % admonition_name.lower()
- # titles are generally optional
- if title:
- title = '%s' % title
+ # titles are generally optional
+ if title:
+ title = "%s" % title
- # process the admonition body like regular markdown
- body = self.md._run_block_gamut("\n%s\n" % self.md._uniform_outdent(body)[1])
+ # process the admonition body like regular markdown
+ body = self.md._run_block_gamut("\n%s\n" % self.md._uniform_outdent(body)[1])
- # indent the body before placing inside the aside block
- admonition = self.md._uniform_indent(
- '{}\n{}\n\n{}\n'.format(admonition_type, title, body),
- self.md.tab, False
- )
- # wrap it in an aside
- admonition = ''.format(admonition_class, admonition)
- # now indent the whole admonition back to where it started
- return self.md._uniform_indent(admonition, lead_indent, False)
+ # indent the body before placing inside the aside block
+ admonition = self.md._uniform_indent(
+ "{}\n{}\n\n{}\n".format(admonition_type, title, body), self.md.tab, False
+ )
+ # wrap it in an aside
+ admonition = ''.format(admonition_class, admonition)
+ # now indent the whole admonition back to where it started
+ return self.md._uniform_indent(admonition, lead_indent, False)
- def run(self, text):
- return self.admonitions_re.sub(self.sub, text)
+ def run(self, text):
+ return self.admonitions_re.sub(self.sub, text)
class Alerts(Extra):
- '''
- Markdown Alerts as per
- https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
- '''
+ """
+ Markdown Alerts as per
+ https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
+ """
- name = 'alerts'
- order = (), (Stage.BLOCK_QUOTES, )
+ name = "alerts"
+ order = (), (Stage.BLOCK_QUOTES,)
- alert_re = re.compile(r'''
+ alert_re = re.compile(
+ r"""
\s*
- ''', re.X
- )
+ """,
+ re.X,
+ )
- def test(self, text):
- return "" in text
+ def test(self, text):
+ return "
" in text
- def sub(self, match: re.Match) -> str:
- typ = match["type"].lower()
- heading = f"{match['type'].title()}"
- contents = match["contents"].strip()
- if match["closing_tag"]:
- return f'
'''
- on_newline: bool
- '''Replace single new line characters with
when True'''
+ """Options for the `Breaks` extra"""
+
+ on_backslash: bool
+ """Replace backslashes at the end of a line with
"""
+ on_newline: bool
+ """Replace single new line characters with
when True"""
class Breaks(Extra):
- name = 'breaks'
- order = (), (Stage.ITALIC_AND_BOLD,)
- options: _BreaksExtraOpts
+ name = "breaks"
+ order = (), (Stage.ITALIC_AND_BOLD,)
+ options: _BreaksExtraOpts
- def run(self, text):
- on_backslash = self.options.get('on_backslash', False)
- on_newline = self.options.get('on_newline', False)
+ def run(self, text):
+ on_backslash = self.options.get("on_backslash", False)
+ on_newline = self.options.get("on_newline", False)
- if on_backslash and on_newline:
- pattern = r' *\\?'
- elif on_backslash:
- pattern = r'(?: *\\| {2,})'
- elif on_newline:
- pattern = r' *'
- else:
- pattern = r' {2,}'
+ if on_backslash and on_newline:
+ pattern = r" *\\?"
+ elif on_backslash:
+ pattern = r"(?: *\\| {2,})"
+ elif on_newline:
+ pattern = r" *"
+ else:
+ pattern = r" {2,}"
- break_tag = "
)", break_tag, text)
+ break_tag = "
)", break_tag, text)
- return text
+ return text
class CodeFriendly(ItalicAndBoldProcessor):
- '''
- Disable _ and __ for em and strong.
- '''
- name = 'code-friendly'
-
- def sub(self, match: re.Match) -> str:
- syntax = match.group(1)
- text: str = match.string[match.start(): match.end()]
- if '_' in syntax:
- # if using _this_ syntax, hash the whole thing so that it doesn't get processed
- key = _hash_text(text)
- self.hash_table[key] = text
- return key
- elif '_' in text:
- # if the text within the bold/em markers contains '_' then hash those contents to protect them from em_re
- text = text[len(syntax): -len(syntax)]
- key = _hash_text(text)
- self.hash_table[key] = text
- return syntax + key + syntax
- # if no underscores are present, the text is fine and we can just leave it alone
- return super().sub(match)
+ """
+ Disable _ and __ for em and strong.
+ """
+
+ name = "code-friendly"
+
+ def sub(self, match: re.Match) -> str:
+ syntax = match.group(1)
+ text: str = match.string[match.start() : match.end()]
+ if "_" in syntax:
+ # if using _this_ syntax, hash the whole thing so that it doesn't get processed
+ key = _hash_text(text)
+ self.hash_table[key] = text
+ return key
+ elif "_" in text:
+ # if the text within the bold/em markers contains '_' then hash those contents to protect them from em_re
+ text = text[len(syntax) : -len(syntax)]
+ key = _hash_text(text)
+ self.hash_table[key] = text
+ return syntax + key + syntax
+ # if no underscores are present, the text is fine and we can just leave it alone
+ return super().sub(match)
class FencedCodeBlocks(Extra):
- '''
- Allows a code block to not have to be indented
- by fencing it with '```' on a line before and after. Based on
-
')
-
- def sub(self, match: re.Match) -> str:
- lexer_name = match.group(2)
- codeblock = match.group(3)
- codeblock = codeblock[:-1] # drop one trailing newline
-
- # Use pygments only if not using the highlightjs-lang extra
- if lexer_name and "highlightjs-lang" not in self.md.extras:
- lexer = self.md._get_pygments_lexer(lexer_name)
- if lexer:
- leading_indent = ' '*(len(match.group(1)) - len(match.group(1).lstrip()))
- return self._code_block_with_lexer_sub(codeblock, leading_indent, lexer)
-
- # Fenced code blocks need to be outdented before encoding, and then reapplied
- leading_indent = ' ' * (len(match.group(1)) - len(match.group(1).lstrip()))
- if codeblock:
- # only run the codeblock through the outdenter if not empty
- leading_indent, codeblock = self.md._uniform_outdent(codeblock, max_outdent=leading_indent)
-
- codeblock = self.md._encode_code(codeblock)
-
- tags = self.tags(lexer_name)
-
- return "\n{}{}{}\n{}{}\n".format(leading_indent, tags[0], codeblock, leading_indent, tags[1])
-
- def run(self, text):
- return self.fenced_code_block_re.sub(self.sub, text)
+ """,
+ re.M | re.X | re.S,
+ )
+
+ def test(self, text):
+ if "```" not in text:
+ return False
+ if self.md.stage == Stage.PREPROCESS and not self.md.safe_mode:
+ return True
+ if self.md.stage == Stage.LINK_DEFS and self.md.safe_mode:
+ return True
+ return self.md.stage == Stage.BLOCK_GAMUT
+
+ def _code_block_with_lexer_sub(self, codeblock: str, leading_indent: str, lexer) -> str:
+ """
+ Args:
+ codeblock: the codeblock to format
+ leading_indent: the indentation to prefix the block with
+ lexer (pygments.Lexer): the lexer to use
+ """
+ formatter_opts = self.md.extras["fenced-code-blocks"] or {}
+
+ def unhash_code(codeblock):
+ for key, sanitized in list(self.md.html_spans.items()):
+ codeblock = codeblock.replace(key, sanitized)
+ replacements = [("&", "&"), ("<", "<"), (">", ">")]
+ for old, new in replacements:
+ codeblock = codeblock.replace(old, new)
+ return codeblock
+
+ # remove leading indent from code block
+ _, codeblock = self.md._uniform_outdent(codeblock, max_outdent=leading_indent)
+
+ codeblock = unhash_code(codeblock)
+ colored = self.md._color_with_pygments(codeblock, lexer, **formatter_opts)
+
+ # add back the indent to all lines
+ return "\n%s\n" % self.md._uniform_indent(colored, leading_indent, True)
+
+ def tags(self, lexer_name: str) -> tuple[str, str]:
+ """
+ Returns the tags that the encoded code block will be wrapped in, based
+ upon the lexer name.
+
+ This function can be overridden by subclasses to piggy-back off of the
+ fenced code blocks syntax (see `Mermaid` extra).
+
+ Returns:
+ The opening and closing tags, as strings within a tuple
+ """
+ pre_class = self.md._html_class_str_from_tag("pre")
+ if "highlightjs-lang" in self.md.extras and lexer_name:
+ code_class = ' class="{} language-{}"'.format(lexer_name, lexer_name)
+ else:
+ code_class = self.md._html_class_str_from_tag("code")
+ return ("'.format(pre_class, code_class), '
")
+
+ def sub(self, match: re.Match) -> str:
+ lexer_name = match.group(2)
+ codeblock = match.group(3)
+ codeblock = codeblock[:-1] # drop one trailing newline
+
+ # Use pygments only if not using the highlightjs-lang extra
+ if lexer_name and "highlightjs-lang" not in self.md.extras:
+ lexer = self.md._get_pygments_lexer(lexer_name)
+ if lexer:
+ leading_indent = " " * (len(match.group(1)) - len(match.group(1).lstrip()))
+ return self._code_block_with_lexer_sub(codeblock, leading_indent, lexer)
+
+ # Fenced code blocks need to be outdented before encoding, and then reapplied
+ leading_indent = " " * (len(match.group(1)) - len(match.group(1).lstrip()))
+ if codeblock:
+ # only run the codeblock through the outdenter if not empty
+ leading_indent, codeblock = self.md._uniform_outdent(codeblock, max_outdent=leading_indent)
+
+ codeblock = self.md._encode_code(codeblock)
+
+ tags = self.tags(lexer_name)
+
+ return "\n{}{}{}\n{}{}\n".format(leading_indent, tags[0], codeblock, leading_indent, tags[1])
+
+ def run(self, text):
+ return self.fenced_code_block_re.sub(self.sub, text)
class Latex(Extra):
- '''
- Convert $ and $$ to tags for inline and block math.
- '''
- name = 'latex'
- order = (Stage.CODE_BLOCKS, FencedCodeBlocks), ()
+ """
+ Convert $ and $$ to tags for inline and block math.
+ """
+
+ name = "latex"
+ order = (Stage.CODE_BLOCKS, FencedCodeBlocks), ()
- _single_dollar_re = re.compile(r'(?(.*?)".format(pre_class, code_class), "
- _triple_re = re.compile(r'```(.*?)```', re.DOTALL) # Wrapped in a code block ```
- _single_re = re.compile(r'(?(.*?)
", re.DOTALL) # Wraped in
+ _triple_re = re.compile(r"```(.*?)```", re.DOTALL) # Wrapped in a code block ```
+ _single_re = re.compile(r"(?"
- self.code_blocks[placeholder] = match.group(0)
- return placeholder
+ def code_placeholder(self, match):
+ placeholder = f""
+ self.code_blocks[placeholder] = match.group(0)
+ return placeholder
- def run(self, text):
- try:
- import latex2mathml.converter
- self.converter = latex2mathml.converter
- except ImportError:
- raise ImportError('The "latex" extra requires the "latex2mathml" package to be installed.')
+ def run(self, text):
+ try:
+ import latex2mathml.converter
- # Escape by replacing with a code block
- text = self._pre_code_block_re.sub(self.code_placeholder, text)
- text = self._single_re.sub(self.code_placeholder, text)
- text = self._triple_re.sub(self.code_placeholder, text)
+ self.converter = latex2mathml.converter
+ except ImportError:
+ raise ImportError('The "latex" extra requires the "latex2mathml" package to be installed.')
- text = self._single_dollar_re.sub(self._convert_single_match, text)
- text = self._double_dollar_re.sub(self._convert_double_match, text)
+ # Escape by replacing with a code block
+ text = self._pre_code_block_re.sub(self.code_placeholder, text)
+ text = self._single_re.sub(self.code_placeholder, text)
+ text = self._triple_re.sub(self.code_placeholder, text)
- # Convert placeholder tag back to original code
- for placeholder, code_block in self.code_blocks.items():
- text = text.replace(placeholder, code_block)
+ text = self._single_dollar_re.sub(self._convert_single_match, text)
+ text = self._double_dollar_re.sub(self._convert_double_match, text)
- return text
+ # Convert placeholder tag back to original code
+ for placeholder, code_block in self.code_blocks.items():
+ text = text.replace(placeholder, code_block)
+
+ return text
class LinkPatterns(Extra):
- '''
- Auto-link given regex patterns in text (e.g. bug number
- references, revision number references).
- '''
- name = 'link-patterns'
- order = (Stage.LINKS,), ()
- options: _link_patterns
-
- _basic_link_re = re.compile(r'!?\[.*?\]\(.*?\)')
-
- def run(self, text):
- link_from_hash = {}
- for regex, repl in self.options:
- replacements = []
- for match in regex.finditer(text):
- if any(self.md._match_overlaps_substr(text, match, h) for h in link_from_hash):
- continue
-
- if callable(repl):
- href = repl(match)
- else:
- href = match.expand(repl)
- replacements.append((match.span(), href))
- for (start, end), href in reversed(replacements):
-
- # Do not match against links inside brackets.
- if text[start - 1:start] == '[' and text[end:end + 1] == ']':
- continue
-
- # Do not match against links in the standard markdown syntax.
- if text[start - 2:start] == '](' or text[end:end + 2] == '")':
- continue
-
- # Do not match against links which are escaped.
- if text[start - 3:start] == '"""' and text[end:end + 3] == '"""':
- text = text[:start - 3] + text[start:end] + text[end + 3:]
- continue
-
- # search the text for anything that looks like a link
- is_inside_link = False
- for link_re in (self.md._auto_link_re, self._basic_link_re):
- for match in link_re.finditer(text):
- if any((r[0] <= start and end <= r[1]) for r in match.regs):
- # if the link pattern start and end pos is within the bounds of
- # something that looks like a link, then don't process it
- is_inside_link = True
- break
- else:
- continue
- break
-
- if is_inside_link:
- continue
-
- escaped_href = (
- href.replace('"', '"') # b/c of attr quote
- # To avoid markdown and :
- .replace('*', self.md._escape_table['*'])
- .replace('_', self.md._escape_table['_']))
- link = '{}'.format(escaped_href, text[start:end])
- hash = _hash_text(link)
- link_from_hash[hash] = link
- text = text[:start] + hash + text[end:]
- for hash, link in list(link_from_hash.items()):
- text = text.replace(hash, link)
- return text
-
- def test(self, text):
- return True
+ """
+ Auto-link given regex patterns in text (e.g. bug number
+ references, revision number references).
+ """
+
+ name = "link-patterns"
+ order = (Stage.LINKS,), ()
+ options: _link_patterns
+
+ _basic_link_re = re.compile(r"!?\[.*?\]\(.*?\)")
+
+ def run(self, text):
+ link_from_hash = {}
+ for regex, repl in self.options:
+ replacements = []
+ for match in regex.finditer(text):
+ if any(self.md._match_overlaps_substr(text, match, h) for h in link_from_hash):
+ continue
+
+ if callable(repl):
+ href = repl(match)
+ else:
+ href = match.expand(repl)
+ replacements.append((match.span(), href))
+ for (start, end), href in reversed(replacements):
+ # Do not match against links inside brackets.
+ if text[start - 1 : start] == "[" and text[end : end + 1] == "]":
+ continue
+
+ # Do not match against links in the standard markdown syntax.
+ if text[start - 2 : start] == "](" or text[end : end + 2] == '")':
+ continue
+
+ # Do not match against links which are escaped.
+ if text[start - 3 : start] == '"""' and text[end : end + 3] == '"""':
+ text = text[: start - 3] + text[start:end] + text[end + 3 :]
+ continue
+
+ # search the text for anything that looks like a link
+ is_inside_link = False
+ for link_re in (self.md._auto_link_re, self._basic_link_re):
+ for match in link_re.finditer(text):
+ if any((r[0] <= start and end <= r[1]) for r in match.regs):
+ # if the link pattern start and end pos is within the bounds of
+ # something that looks like a link, then don't process it
+ is_inside_link = True
+ break
+ else:
+ continue
+ break
+
+ if is_inside_link:
+ continue
+
+ escaped_href = (
+ href.replace('"', """) # b/c of attr quote
+ # To avoid markdown and :
+ .replace("*", self.md._escape_table["*"])
+ .replace("_", self.md._escape_table["_"])
+ )
+ link = '{}'.format(escaped_href, text[start:end])
+ hash = _hash_text(link)
+ link_from_hash[hash] = link
+ text = text[:start] + hash + text[end:]
+ for hash, link in list(link_from_hash.items()):
+ text = text.replace(hash, link)
+ return text
+
+ def test(self, text):
+ return True
class MarkdownInHTML(Extra):
- '''
- Allow the use of `markdown="1"` in a block HTML tag to
- have markdown processing be done on its contents. Similar to
-
')
- return super().tags(lexer_name)
+ def tags(self, lexer_name):
+ if lexer_name == "mermaid":
+ return ('
")
+ return super().tags(lexer_name)
class MiddleWordEm(ItalicAndBoldProcessor):
- '''
- Allows or disallows emphasis syntax in the middle of words,
- defaulting to allow. Disabling this means that `this_text_here` will not be
- converted to `thistexthere`.
- '''
- name = 'middle-word-em'
- order = (CodeFriendly,), (Stage.ITALIC_AND_BOLD,)
-
- def __init__(self, md: Markdown, options: Union[dict, bool, None]):
- '''
- Args:
- md: the markdown instance
- options: can be bool for backwards compatibility but will be converted to a dict
- in the constructor. All options are:
- - allowed (bool): whether to allow emphasis in the middle of a word.
- If `options` is a bool it will be placed under this key.
- '''
- if isinstance(options, bool):
- options = {'allowed': options}
- else:
- options = options or {}
- options.setdefault('allowed', True)
- super().__init__(md, options)
-
- self.liberal_em_re = self.em_re
- if not options['allowed']:
- self.em_re = re.compile(r'(?<=\b)%s(?=\b)' % self.em_re.pattern, self.em_re.flags)
- self.liberal_em_re = re.compile(
- r'''
+ """
+ Allows or disallows emphasis syntax in the middle of words,
+ defaulting to allow. Disabling this means that `this_text_here` will not be
+ converted to `thistexthere`.
+ """
+
+ name = "middle-word-em"
+ order = (CodeFriendly,), (Stage.ITALIC_AND_BOLD,)
+
+ def __init__(self, md: Markdown, options: Union[dict, bool, None]):
+ """
+ Args:
+ md: the markdown instance
+ options: can be bool for backwards compatibility but will be converted to a dict
+ in the constructor. All options are:
+ - allowed (bool): whether to allow emphasis in the middle of a word.
+ If `options` is a bool it will be placed under this key.
+ """
+ if isinstance(options, bool):
+ options = {"allowed": options}
+ else:
+ options = options or {}
+ options.setdefault("allowed", True)
+ super().__init__(md, options)
+
+ self.liberal_em_re = self.em_re
+ if not options["allowed"]:
+ self.em_re = re.compile(r"(?<=\b)%s(?=\b)" % self.em_re.pattern, self.em_re.flags)
+ self.liberal_em_re = re.compile(
+ r"""
( # \1 - must be a single em char in the middle of a word
(? str:
- syntax = match.group(1)
- if len(syntax) != 1:
- # strong syntax
- return super().sub(match)
- return '%s' % match.group(2)
+ """,
+ re.S | re.X,
+ )
+
+ def run(self, text):
+ if self.options["allowed"]:
+ # if middle word em is allowed, do nothing. This extra's only use is to prevent them
+ return text
+
+ # run strong and whatnot first
+ # this also will process all strict ems
+ text = super().run(text)
+ if self.md.order < self.md.stage:
+ # hash all non-valid ems
+ text = self.liberal_em_re.sub(self.sub_hash, text)
+ return text
+
+ def sub(self, match: re.Match) -> str:
+ syntax = match.group(1)
+ if len(syntax) != 1:
+ # strong syntax
+ return super().sub(match)
+ return "%s" % match.group(2)
class Numbering(Extra):
- '''
- Support of generic counters. Non standard extension to
- allow sequential numbering of figures, tables, equations, exhibits etc.
- '''
-
- name = 'numbering'
- order = (Stage.LINK_DEFS,), ()
-
- def run(self, text):
- # First pass to define all the references
- regex_defns = re.compile(r'''
+ """
+ Support of generic counters. Non standard extension to
+ allow sequential numbering of figures, tables, equations, exhibits etc.
+ """
+
+ name = "numbering"
+ order = (Stage.LINK_DEFS,), ()
+
+ def run(self, text):
+ # First pass to define all the references
+ regex_defns = re.compile(
+ r"""
\[\#(\w+) # the counter. Open square plus hash plus a word \1
([^@]*) # Some optional characters, that aren't an @. \2
@(\w+) # the id. Should this be normed? \3
([^\]]*)\] # The rest of the text up to the terminating ] \4
- ''', re.VERBOSE)
- regex_subs = re.compile(r"\[@(\w+)\s*\]") # [@ref_id]
- counters = {}
- references = {}
- replacements = []
- definition_html = '
- blocks.
- '''
-
- name = 'pyshell'
- order = (), (Stage.LISTS,)
-
- def test(self, text):
- return ">>>" in text
-
- def sub(self, match: re.Match) -> str:
- if "fenced-code-blocks" in self.md.extras:
- dedented = _dedent(match.group(0))
- return self.md.extra_classes['fenced-code-blocks'].run("```pycon\n" + dedented + "```\n")
-
- lines = match.group(0).splitlines(0)
- _dedentlines(lines)
- indent = ' ' * self.md.tab_width
- s = ('\n' # separate from possible cuddled paragraph
- + indent + ('\n'+indent).join(lines)
- + '\n')
- return s
-
- def run(self, text):
- less_than_tab = self.md.tab_width - 1
- _pyshell_block_re = re.compile(r"""
+ """
+ Treats unindented Python interactive shell sessions as
+ blocks.
+ """
+
+ name = "pyshell"
+ order = (), (Stage.LISTS,)
+
+ def test(self, text):
+ return ">>>" in text
+
+ def sub(self, match: re.Match) -> str:
+ if "fenced-code-blocks" in self.md.extras:
+ dedented = _dedent(match.group(0))
+ return self.md.extra_classes["fenced-code-blocks"].run("```pycon\n" + dedented + "```\n")
+
+ lines = match.group(0).splitlines(0)
+ _dedentlines(lines)
+ indent = " " * self.md.tab_width
+ s = (
+ "\n" # separate from possible cuddled paragraph
+ + indent
+ + ("\n" + indent).join(lines)
+ + "\n"
+ )
+ return s
+
+ def run(self, text):
+ less_than_tab = self.md.tab_width - 1
+ _pyshell_block_re = re.compile(
+ r"""
^([ ]{0,%d})>>>[ ].*\n # first line
^(\1[^\S\n]*\S.*\n)* # any number of subsequent lines with at least one character
(?=^\1?\n|\Z) # ends with a blank line or end of document
- """ % less_than_tab, re.M | re.X)
+ """
+ % less_than_tab,
+ re.M | re.X,
+ )
- return _pyshell_block_re.sub(self.sub, text)
+ return _pyshell_block_re.sub(self.sub, text)
class SmartyPants(Extra):
- '''
- Replaces ' and " with curly quotation marks or curly
- apostrophes. Replaces --, ---, ..., and . . . with en dashes, em dashes,
- and ellipses.
- '''
- name = 'smarty-pants'
- order = (), (Stage.SPAN_GAMUT,)
-
- _opening_single_quote_re = re.compile(r"(? str:
- text = self._apostrophe_year_re.sub(r"’\1", text)
- for c in self._contractions:
- text = text.replace("'%s" % c, "’%s" % c)
- text = text.replace("'%s" % c.capitalize(),
- "’%s" % c.capitalize())
- return text
-
- def run(self, text):
- """Fancifies 'single quotes', "double quotes", and apostrophes.
- Converts --, ---, and ... into en dashes, em dashes, and ellipses.
-
- Inspiration is: \1", text)
+ def run(self, text):
+ return self._strike_re.sub(r"\1", text)
- def test(self, text):
- return '~~' in text
+ def test(self, text):
+ return "~~" in text
class Tables(Extra):
- '''
- Tables using the same format as GFM
- ']
- cols = [re.sub(escape_bar_re, '|', cell.strip()) for cell in re.split(split_bar_re, re.sub(trim_bar_re, "", re.sub(trim_space_re, "", head)))]
- for col_idx, col in enumerate(cols):
- hlines.append(' ')
- hlines.append('')
-
- # tbody
- body = body.strip('\n')
- if body:
- hlines.append('')
- for line in body.split('\n'):
- hlines.append('{} '.format(
- align_from_col_idx.get(col_idx, ''),
- self.md._run_span_gamut(col)
- ))
- hlines.append('')
- cols = [re.sub(escape_bar_re, '|', cell.strip()) for cell in re.split(split_bar_re, re.sub(trim_bar_re, "", re.sub(trim_space_re, "", line)))]
- for col_idx, col in enumerate(cols):
- hlines.append(' ')
- hlines.append('')
- hlines.append('')
-
- return '\n'.join(hlines) + '\n'
+ """
+ % (less_than_tab, less_than_tab, less_than_tab),
+ re.M | re.X,
+ )
+ return table_re.sub(self.sub, text)
+
+ def sub(self, match: re.Match) -> str:
+ trim_space_re = r"^\s+|\s+$"
+ trim_bar_re = r"^\||\|$"
+ split_bar_re = r"^\||(?" % self.md._html_class_str_from_tag("table"),
+ "" % self.md._html_class_str_from_tag("thead"),
+ "{} '.format(
- align_from_col_idx.get(col_idx, ''),
- self.md._run_span_gamut(col)
- ))
- hlines.append('",
+ ]
+ cols = [
+ re.sub(escape_bar_re, "|", cell.strip())
+ for cell in re.split(split_bar_re, re.sub(trim_bar_re, "", re.sub(trim_space_re, "", head)))
+ ]
+ for col_idx, col in enumerate(cols):
+ hlines.append(
+ " ")
+ hlines.append("")
+
+ # tbody
+ body = body.strip("\n")
+ if body:
+ hlines.append("")
+ for line in body.split("\n"):
+ hlines.append("{} ".format(align_from_col_idx.get(col_idx, ""), self.md._run_span_gamut(col))
+ )
+ hlines.append("")
+ cols = [
+ re.sub(escape_bar_re, "|", cell.strip())
+ for cell in re.split(
+ split_bar_re, re.sub(trim_bar_re, "", re.sub(trim_space_re, "", line))
+ )
+ ]
+ for col_idx, col in enumerate(cols):
+ hlines.append(
+ " ")
+ hlines.append("")
+ hlines.append("")
+
+ return "\n".join(hlines) + "\n"
class TelegramSpoiler(Extra):
- name = 'tg-spoiler'
- order = (), (Stage.ITALIC_AND_BOLD,)
+ name = "tg-spoiler"
+ order = (), (Stage.ITALIC_AND_BOLD,)
- _tg_spoiler_re = re.compile(r"\|\|\s?(.+?)\s?\|\|", re.S)
+ _tg_spoiler_re = re.compile(r"\|\|\s?(.+?)\s?\|\|", re.S)
- def run(self, text):
- return self._tg_spoiler_re.sub(r"{} ".format(
+ align_from_col_idx.get(col_idx, ""), self.md._run_span_gamut(col)
+ )
+ )
+ hlines.append("', 2)
- for cell in rows[0]:
- add_hline(f" ', 2)
- add_hline('', 1)
- # Only one header row allowed.
- rows = rows[1:]
- # If no more rows, don't create a tbody.
- if rows:
- add_hline('', 1)
- for row in rows:
- add_hline('{format_cell(cell)} ", 3)
- add_hline('', 2)
- for cell in row:
- add_hline(f' ', 2)
- add_hline('', 1)
- add_hline('')
- return '\n'.join(hlines) + '\n'
-
- def test(self, text):
- return '||' in text
+ """
+ % less_than_tab,
+ re.M | re.X,
+ )
+ return wiki_table_re.sub(self.sub, text)
+
+ def sub(self, match: re.Match) -> str:
+ ttext = match.group(0).strip()
+ rows = []
+ for line in ttext.splitlines(0):
+ line = line.strip()[2:-2].strip()
+ row = [c.strip() for c in re.split(r"(?" % self.md._html_class_str_from_tag("table"))
+ # Check if first cell of first row is a header cell. If so, assume the whole row is a header row.
+ if rows and rows[0] and re.match(r"^\s*~", rows[0][0]):
+ add_hline("" % self.md._html_class_str_from_tag("thead"), 1)
+ add_hline("{format_cell(cell)} ', 3)
- add_hline('", 2)
+ for cell in rows[0]:
+ add_hline(f" ", 2)
+ add_hline("", 1)
+ # Only one header row allowed.
+ rows = rows[1:]
+ # If no more rows, don't create a tbody.
+ if rows:
+ add_hline("", 1)
+ for row in rows:
+ add_hline("{format_cell(cell)} ", 3)
+ add_hline("", 2)
+ for cell in row:
+ add_hline(f" ", 2)
+ add_hline("", 1)
+ add_hline("")
+ return "\n".join(hlines) + "\n"
+
+ def test(self, text):
+ return "||" in text
# Register extras
@@ -3795,225 +3904,235 @@ def test(self, text):
def calculate_toc_html(toc: Union[list[tuple[int, str, str]], None]) -> Optional[str]:
- """Return the HTML for the current TOC.
-
- This expects the `_toc` attribute to have been set on this instance.
- """
- if toc is None:
- return None
-
- def indent():
- return ' ' * (len(h_stack) - 1)
- lines = []
- h_stack = [0] # stack of header-level numbers
- for level, id, name in toc:
- if level > h_stack[-1]:
- lines.append("%s{format_cell(cell)} ", 3)
+ add_hline("" % indent())
- h_stack.append(level)
- elif level == h_stack[-1]:
- lines[-1] += ""
- else:
- while level < h_stack[-1]:
- h_stack.pop()
- if not lines[-1].endswith(""):
- lines[-1] += ""
- lines.append("%s
" % indent())
- lines.append('{}" % indent())
+ h_stack.append(level)
+ elif level == h_stack[-1]:
+ lines[-1] += ""
+ else:
+ while level < h_stack[-1]:
+ h_stack.pop()
+ if not lines[-1].endswith(""):
+ lines[-1] += ""
+ lines.append("%s
" % indent())
+ lines.append('{}