From 78a82cbd8eda088b1bfb0a7626913b83623e5481 Mon Sep 17 00:00:00 2001 From: WMHN1872265132 <1872265132@qq.com> Date: Wed, 25 Mar 2026 10:52:11 +0800 Subject: [PATCH 1/3] Add keyCommandsDoc --- source/keyCommandsDoc.py | 290 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 source/keyCommandsDoc.py diff --git a/source/keyCommandsDoc.py b/source/keyCommandsDoc.py new file mode 100644 index 0000000..dfdb307 --- /dev/null +++ b/source/keyCommandsDoc.py @@ -0,0 +1,290 @@ +# -*- coding: UTF-8 -*- +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2010-2024 NV Access Limited, Mesar Hameed, Takuya Nishimoto +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +""" +Generates the Key Commands document from the User Guide. +Works as a Python Markdown Extension: +https://python-markdown.github.io/extensions/ + +Refer to user guide standards for more information on syntax rules: +https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/userGuideStandards.md +""" + +from enum import auto, Enum, IntEnum, StrEnum +import re +from collections.abc import Iterator + +from markdown import Extension, Markdown +from markdown.preprocessors import Preprocessor + + +LINE_END = "\r\n" + + +class Section(IntEnum): + """Sections must be nested in this order.""" + + HEADER = auto() + BODY = auto() + + +class Command(StrEnum): + TITLE = "title" + BEGIN_INCLUDE = "beginInclude" + END_INCLUDE = "endInclude" + SETTING = "setting" + SETTINGS_SECTION = "settingsSection" + + +class Regex(Enum): + COMMAND = re.compile(r"^$") + HEADING = re.compile(r"^(?P#+)(?P.*)$") + SETTING_SINGLE_KEY = re.compile(r"^[^|]+?[::]\s*(.+?)\s*$") + TABLE_ROW = re.compile(r"^(\|.*\|)$") + + +class KeyCommandsError(Exception): + """Raised due to an error encountered in the User Guide related to generation of the Key Commands document.""" + + +class KeyCommandsExtension(Extension): + # Magic number, priorities are not well documented. + # It's unclear what the range of priorities are to compare to, but 25 seems to work, 1 doesn't. + # See https://python-markdown.github.io/extensions/api/#registries + PRIORITY = 25 + + def extendMarkdown(self, md: Markdown): + md.preprocessors.register(KeyCommandsPreprocessor(md), "key_commands", self.PRIORITY) + + +class KeyCommandsPreprocessor(Preprocessor): + def __init__(self, md: Markdown | None): + super().__init__(md) + self.initialize() + + def initialize(self): + self._ugLines: Iterator[str] = iter(()) + self._kcLines: list[str] = [] + #: The current section of the key commands file. + self._kcSect: Section = Section.HEADER + #: The current stack of headings. + self._headings: list[re.Match] = [] + #: The 0 based level of the last heading in L{_headings} written to the key commands file. + self._kcLastHeadingLevel: int = -1 + #: Whether lines which aren't commands should be written to the key commands file as is. + self._kcInclude: bool = False + #: The header row for settings sections. + self._settingsHeaderRow: str | None = None + #: The number of layouts for settings in a settings section. + self._settingsNumLayouts: int = 0 + #: The current line number being processed, used to present location of syntax errors + self._lineNum: int = 0 + # We want to skip the title line to replace it with the KC:TITLE command argument. + self._skippedTitle = False + + def run(self, lines: list[str]) -> list[str]: + # Turn this into an iterator so we can use next() to seek through lines. + self._ugLines = iter(lines) + for line in self._ugLines: + line = line.strip() + self._lineNum += 1 + + # We want to skip the title line to replace it with the KC:TITLE command argument. + if line.startswith("# ") and not self._skippedTitle: + self._skippedTitle = True + continue + + m = Regex.COMMAND.value.match(line) + if m: + self._command(**m.groupdict()) + continue + + m = Regex.HEADING.value.match(line) + if m: + self._heading(m) + continue + + if self._kcInclude: + self._kcLines.append(line) + + return self._kcLines.copy() + + def _command(self, cmd: Command | None = None, arg: str | None = None): + # Handle header commands. + if cmd == Command.TITLE.value: + if self._kcSect > Section.HEADER: + raise KeyCommandsError(f"{self._lineNum}, title command is not valid here") + # Write the title and two blank lines to complete the header section. + self._kcLines.append("# " + arg + LINE_END * 2) + # Add table of contents marker + self._kcLines.append("[TOC]" + LINE_END * 2) + self._kcSect = Section.BODY + return + + elif self._kcSect == Section.HEADER: + raise KeyCommandsError(f"{self._lineNum}, title must be the first command") + + if cmd == Command.BEGIN_INCLUDE.value: + self._writeHeadings() + self._kcInclude = True + elif cmd == Command.END_INCLUDE.value: + self._kcInclude = False + self._kcLines.append("") + + elif cmd == Command.SETTINGS_SECTION.value: + # The argument is the table header row for the settings section. + # Replace legacy t2t header syntax with markdown syntax. + # TODO: when migrating all userGuide translations to po files, + # update the base userGuide to replace the legacy syntax with markdown syntax + # and remove this. + self._settingsHeaderRow = arg.replace("||", "|") + # There are name and description columns. + # Each of the remaining columns provides keystrokes for one layout. + # There's one less delimiter than there are columns, hence subtracting 1 instead of 2. + self._settingsNumLayouts = arg.strip("|").count("|") - 1 + if self._settingsNumLayouts < 1: + raise KeyCommandsError( + f"{self._lineNum}, settingsSection command must specify the header row for a table" + " summarising the settings", + ) + + elif cmd == Command.SETTING.value: + self._handleSetting() + + else: + raise KeyCommandsError(f"{self._lineNum}, Invalid command {cmd}") + + def _seekNonEmptyLine(self) -> str: + """Seeks to the next non-empty line in the user guide.""" + line = next(self._ugLines).strip() + self._lineNum += 1 + while not line: + try: + line = next(self._ugLines).strip() + except StopIteration: + return line + self._lineNum += 1 + return line + + def _areHeadingsPending(self) -> bool: + return self._kcLastHeadingLevel < len(self._headings) - 1 + + def _writeHeadings(self): + level = self._kcLastHeadingLevel + 1 + # Only write headings we haven't yet written. + for level, heading in enumerate(self._headings[level:], level): + self._kcLines.append(heading.group(0)) + self._kcLastHeadingLevel = level + + def _heading(self, m: re.Match, appendHeading: bool = True): + # We work with 0 based heading levels. + # Ignoring the title, the highest heading in a markdown document is 2 (##). + # Thus why we must subtract 2 here. + level = len(m.group("id")) - 2 + try: + del self._headings[level:] + except IndexError: + pass + if appendHeading: + self._headings.append(m) + self._kcLastHeadingLevel = min(self._kcLastHeadingLevel, level - 1) + + def _handleSetting(self): + if not self._settingsHeaderRow: + raise KeyCommandsError( + "%d, setting command cannot be used before settingsSection command" % self._lineNum, + ) + + tableHeadersRequired = False + if not self._kcLines[-2].startswith("|"): + # We are currently not in a table so a header row needs to be written. + tableHeadersRequired = True + + # The next line should be a heading which is the name of the setting. + line = self._seekNonEmptyLine() + m = Regex.HEADING.value.match(line) + if not m: + raise KeyCommandsError(f"{self._lineNum}, setting command must be followed by heading") + name = m.group("txt") + # Although we will present the heading text as a table row rather than a heading, + # we still must track the heading in order to: + # Forget any previous headings deeper than this one, and + # to keep the current heading level up to date. + self._heading(m, appendHeading=False) + + # The next few lines should be table rows for each layout. + # Alternatively, if the key is common to all layouts, + # there will be a single line of text specifying the key after a colon. + keys: list[str] = [] + line = self._seekNonEmptyLine() + m = Regex.SETTING_SINGLE_KEY.value.match(line) + if m: + keys.append(m.group(1)) + else: + firstLoop = True + for _layout in range(self._settingsNumLayouts): + if firstLoop: + firstLoop = False + else: + line = self._seekNonEmptyLine() + + if not Regex.TABLE_ROW.value.match(line): + raise KeyCommandsError( + f"{self._lineNum}, setting command: " + "There must be one table row for each keyboard layout", + ) + + # This is a table row. + # The key will be the second column. + try: + key = line.strip("|").split("|")[1].strip() + except IndexError: + raise KeyCommandsError( + f"{self._lineNum}, setting command: Key entry not found in table row.", + ) + else: + keys.append(key) + + if 1 == len(keys) < self._settingsNumLayouts: + # The key has only been specified once, so it is the same in all layouts. + key = keys[0] + keys[1:] = (key for _layout in range(self._settingsNumLayouts - 1)) + + # There should now be a blank line. + line = next(self._ugLines).strip() + self._lineNum += 1 + if line: + raise KeyCommandsError( + f"{self._lineNum}, setting command: The keyboard shortcuts must be followed by a blank line. " + "Multiple keys must be included in a table. " + f"Erroneous key: {key}", + ) + + # Finally, the next line should be the description. + desc = self._seekNonEmptyLine() + + if self._areHeadingsPending(): + # There are new headings to write. + # If there was a previous settings table, it ends here, so write a blank line. + self._kcLines.append("") + self._writeHeadings() + # New headings were written, so we need to output the header row. + tableHeadersRequired = True + + if tableHeadersRequired: + self._kcLines.append(self._settingsHeaderRow) + numCols = self._settingsNumLayouts + 2 # name + description + layouts + self._kcLines.append("|" + "---|" * numCols) + else: + # this is a continuation of a table. + # Therefore, remove the blank line previously added to end the table. + del self._kcLines[-1] + + self._kcLines.append(f"| {name} | {' | '.join(keys)} | {desc} |") + # Indicate the end of the table with a blank line. + # Note that this may end up getting removed on the next call + # if another setting continues this table. + self._kcLines.append("") From b438d8104049c10c34cdb34d6f6ef09b77c97855 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 22 Apr 2026 15:42:51 +1000 Subject: [PATCH 2/3] Apply suggestion from @seanbudd --- source/keyCommandsDoc.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/keyCommandsDoc.py b/source/keyCommandsDoc.py index dfdb307..6ae22af 100644 --- a/source/keyCommandsDoc.py +++ b/source/keyCommandsDoc.py @@ -1,9 +1,7 @@ -# -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2010-2024 NV Access Limited, Mesar Hameed, Takuya Nishimoto -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt """ Generates the Key Commands document from the User Guide. Works as a Python Markdown Extension: From 61e4ac9124fe92a443ac4f2b15633aaf8f33d783 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 22 Apr 2026 15:43:09 +1000 Subject: [PATCH 3/3] Apply suggestion from @seanbudd --- source/keyCommandsDoc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/keyCommandsDoc.py b/source/keyCommandsDoc.py index 6ae22af..a88b3c2 100644 --- a/source/keyCommandsDoc.py +++ b/source/keyCommandsDoc.py @@ -2,6 +2,7 @@ # Copyright (C) 2010-2024 NV Access Limited, Mesar Hameed, Takuya Nishimoto # This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + """ Generates the Key Commands document from the User Guide. Works as a Python Markdown Extension: