player-none 发表于 2025-4-17 21:40:58

【自制】BBCode/Markdown Converter

本帖最后由 player-none 于 2025-4-17 21:45 编辑

【自制】BBCode/Markdown Converter

欢迎前往 GitHub 支持!!!

上一个作品:【自制】DoublePinyin 双拼练习器 V1.0.0

欢迎支持……(没有一个人 Star 的烦恼)

这个作品估计不会长久保持更新,因为确实没啥好更的。除非我以后用这玩意写帖子的时候发现几个 bug……(暗中祈祷不会发现 bug)

最后帖子发出来一看 5 个地方出错了{:10_266:}



创作背景


[*]非常深刻的感受到 Markdown 非常简单、快速,再加上每次发帖子都要遵循同样的规范,不如就把这些规范封装到程序里。
[*]作者看到过 Twilight6 大佬发布的 MarDisTextConverter 源文本转换器,感觉代码写的有些累赘(仅个人观点),就有了自己也写一个的想法。
[*]我可以做出保证,没有任何代码是抄上一条提到的作品的。
[*]闲得无聊


具体代码

pip install PyQt5 qtawesome -i https://mirrors.aliyun.com/pypi/simple


main.py

import sys, re
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
# qtawesome.icon('iconname') 可以快速调用 qtawesome 有的图标
from qtawesome import icon

class Action(QAction):
    """用于简化 QAction 操作"""

    def __init__(self, text, command, iconName='', shortcut='', checkable=False, checked=False):
      """
      初始化方法
      :param text: QAction 显示的文字
      :param command: QAction 执行的命令
      :param iconName: QAction 图标
      :param shortcut: QAction 快捷键
      :param checkable: 是否可以切换选中状态
      :param checked: 是否被选中
      """
      if iconName:
            super().__init__(icon(iconName), text)
      else:
            super().__init__(text)
      self.setShortcut(shortcut)
      self.setCheckable(checkable)
      self.setChecked(checked)
      self.triggered.connect(command)

class PlainTextEdit(QPlainTextEdit):
    """用于简化 QPlainTextEdit 操作"""

    def __init__(self, font, x, y, w, h, parent):
      """
      初始化方法
      :param font: QFont
      :param x: 左上角 x 坐标
      :param y: 左上角 y 坐标
      :param w: 宽度
      :param h: 高度
      :param parent: parent
      """
      super().__init__(parent)
      self.setFont(font)
      self.resize(w, h)
      self.move(x, y)

    def getSelection(self):
      """获取输入框的选区开始和结束位置"""
      cursor = self.textCursor()
      return cursor.selectionStart(), cursor.selectionEnd()

    def setSelection(self, start, end=None):
      """
      设置输入框的选区开始和结束位置
      :param start: 开始位置
      :param end: 结束位置(默认为开始位置)
      """
      if end is None:
            end = start
      # 鬼知道为什么要单独再创建一个 QTextCursor
      cursor = QTextCursor(self.document())
      cursor.setPosition(start)
      cursor.setPosition(end, QTextCursor.KeepAnchor)
      self.setTextCursor(cursor)

class MainWindow(QMainWindow):
    def __init__(self):
      super().__init__()
      self.setWindowIcon(icon('mdi.autorenew'))
      self.setWindowTitle('BBCode/Markdown Converter——BBCode/Markdown 转换器')
      self.setFixedSize(1200, 800)
      self.font8 = QFont('Microsoft YaHei UI', 8)
      self.font10 = QFont('Microsoft YaHei UI', 10)
      self.font11 = QFont('Microsoft YaHei UI', 11)
      # 记录已打开文件的路径
      self.filePath = ''
      # 创建菜单栏以方便用户使用各种各样的操作
      self.menubar = QMenuBar(self)
      self.menubar.setFixedHeight(22)

      # 菜单栏部分好像没啥需要注释的
      self.fileMenu = QMenu('文件(&F)')
      self.newAction = Action('新建(&N)', self.new, 'mdi.file-document', 'Ctrl+N')
      self.openAction = Action('打开(&O)', self.open, 'mdi.folder-open', 'Ctrl+O')
      self.saveAction = Action('保存(&S)', self.save, 'mdi.content-save', 'Ctrl+S')
      self.saveAsAction = Action('另存为(&A)', lambda: self.save(True), 'mdi.content-save-all', 'Ctrl+Alt+A')
      self.quitAction = Action('退出(&Q)', self.close, 'mdi.exit-to-app', 'Alt+F4')
      self.fileMenu.addActions((self.newAction, self.openAction, self.saveAction, self.saveAsAction, self.quitAction))

      self.editMenu = QMenu('编辑(&E)')
      self.boldAction = Action('加粗(&B)', self.bold, 'mdi.format-bold', 'Ctrl+B')
      self.italicAction = Action('斜体(&B)', self.italic, 'mdi.format-italic', 'Ctrl+I')
      self.editMenu.addActions((self.boldAction, self.italicAction))

      self.formatMenu = QMenu('格式(&O)')
      self.bbcodeAction = Action('&BBCode 编辑模式', lambda: (
            self.bbcodeAction.setChecked(True), self.markdownAction.setChecked(False), self.updateStatusLabel()
      ), '', 'Ctrl+Alt+B', True, True)
      self.markdownAction = Action('&Markdown 编辑模式', lambda: (
            self.bbcodeAction.setChecked(False), self.markdownAction.setChecked(True), self.updateStatusLabel()
      ), '', 'Ctrl+Alt+M', True)
      self.fontAction = Action('显示字体(&F)', self.font_, 'mdi.format-font', 'Ctrl+Alt+F')
      self.formatMenu.addActions((self.bbcodeAction, self.markdownAction, self.fontAction))

      self.convertMenu = QMenu('转换(&C)')
      self.convertAction = Action('转换(&C)', self.convert, 'mdi.swap-horizontal-circle', 'Ctrl+Alt+C')
      self.convertMenu.addAction(self.convertAction)

      self.helpMenu = QMenu('帮助(&H)')
      self.aboutAction = Action('关于(&A)', self.help, 'mdi.help-circle')
      self.convertMenu.addAction(self.aboutAction)

      self.menubar.addMenu(self.fileMenu)
      self.menubar.addMenu(self.editMenu)
      self.menubar.addMenu(self.formatMenu)
      self.menubar.addMenu(self.convertMenu)
      self.setMenuBar(self.menubar)

      # 创建工具栏
      self.toolbar = QToolBar(self)
      # 让工具栏的宽度和窗口宽度一样(使其“撑满”整个窗口)
      self.toolbar.setFixedWidth(1200)
      # 设置字体
      self.toolbar.setFont(self.font8)
      # 显示每个 QAction 的文字
      self.toolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
      # 去掉工具栏左边的拖动点位
      self.toolbar.setMovable(False)
      # 添加各种各样的操作
      self.toolbar.addActions((self.newAction, self.openAction, self.saveAction, self.quitAction,
                                 self.boldAction, self.italicAction, self.convertAction))
      # 刚好放在菜单下面
      self.toolbar.move(0, self.menubar.height())

      self.text = PlainTextEdit(self.font11, 0, self.menubar.height() + self.toolbar.height(),
                                  1200, 780 - self.menubar.height() - self.toolbar.height(), self)
      self.text.textChanged.connect(self.updateStatusLabel)
      self.statusLabel = QLabel('编辑模式: BBCode 行数: 1 字符数: 0', self)
      self.statusLabel.setFont(self.font10)
      self.statusLabel.adjustSize()
      self.statusLabel.move(0, 780)

    def closeEvent(self, a0):
      if self.windowTitle() == '*':
            # 提示用户是否要保存
            result = QMessageBox(QMessageBox.Question, '提示', f'是否要保存文件?',
                                 QMessageBox.Save | QMessageBox.No, self).exec()
            if result == QMessageBox.Save:
                self.save()

    def new(self):
      """清空输入框内容"""
      self.text.clear()
      self.setWindowTitle('BBCode/Markdown Converter——BBCode/Markdown 转换器')

    def open(self):
      """打开一个文件"""
      path = QFileDialog.getOpenFileName(self, '打开', '.')
      if not path:
            return
      with open(path, encoding='utf-8') as f:
            self.text.setPlainText(f.read())
            self.filePath = path
            self.setWindowTitle(f'BBCode/Markdown Converter——BBCode/Markdown 转换器 ({path})')

    def save(self, saveAs=True):
      """
      保存输入框里的内容
      :param saveAs: 是否
      """
      if saveAs:
            # 询问路径
            path = QFileDialog.getSaveFileName(self, '另存为', '.')
            if not path:
                return
      else:
            path = self.filePath
            if not path:
                # 询问路径
                path = QFileDialog.getSaveFileName(self, '保存', '.')
                if not path:
                  return
      with open(path, 'w', encoding='utf-8') as f:
            f.write(self.text.toPlainText())
      self.filePath = path
      self.setWindowTitle(f'BBCode/Markdown Converter——BBCode/Markdown 转换器 ({path})')

    def help(self):
      """关于"""
      top = QDialog(self)
      top.setWindowTitle('关于')
      top.icon = QLabel(top)
      top.icon.setPixmap(self.windowIcon().pixmap(64, 64))
      top.icon.move(10, 10)
      top.version = QLabel('版本:V1.0.0', top)
      top.version.setFont(self.font10)
      top.version.adjustSize()
      top.version.move(74, 10)
      top.label = QLabel('BBCode/Markdown Converter 是 dddddgz(player-none)开发的一个小工具,'
                           '用来便捷地进行 BBCode 和 Markdown 之间的转换。', top)
      top.label.setFont(self.font10)
      top.label.adjustSize()
      top.label.move(10, 74)
      top.exec()

    def updateStatusLabel(self):
      """更新 statusLabel 显示的内容"""
      content = self.text.toPlainText()
      mode = ['BBCode', 'Markdown']
      lines = content.count('\n') + 1
      length = len(content)
      self.statusLabel.setText(f'编辑模式: {mode} 行数: {lines} 字符数: {length}')
      # PyQt5 特性,Label 中的文本有固定宽度,无论怎么更改文本宽度都不会变,需要手动 adjustSize 适应宽度
      self.statusLabel.adjustSize()
      self.setWindowTitle(f'*{self.windowTitle().lstrip('*')}')

    def bold(self):
      """加粗"""
      start, end = self.text.getSelection()
      text = self.text.toPlainText()
      bbcode = self.bbcodeAction.isChecked()
      # 后来发现其实根本不用分 start 等不等于 end 的情况讨论
      if bbcode:
            # BBCode 模式()
            self.text.setPlainText(text[:end] + '' + text + '' + text)
      else:
            # Markdown 模式(****)
            self.text.setPlainText(text[:end] + '**' + text + '**' + text)
      self.text.setSelection(start + 2 + bbcode, end + 2 + bbcode)

    def italic(self):
      """斜体"""
      start, end = self.text.getSelection()
      text = self.text.toPlainText()
      bbcode = self.bbcodeAction.isChecked()
      if bbcode:
            # BBCode 模式()
            self.text.setPlainText(text[:end] + '' + text + '' + text)
      else:
            # Markdown 模式(**)
            self.text.setPlainText(text[:end] + '*' + text + '*' + text)
      self.text.setSelection(start + 1 + bbcode * 2, end + 1 + bbcode * 2)

    def font_(self):
      """字体设置界面"""
      dialog = QFontDialog(self)
      dialog.setFont(self.font8)
      dialog.setCurrentFont(self.text.font())
      dialog.accepted.connect(lambda: self.text.setFont(dialog.currentFont()))
      dialog.exec()

    def convert(self):
      """将输入框里的内容视情况转换为 BBCode/Markdown"""
      if self.markdownAction.isChecked():
            # 转换为 BBCode
            self.toBBCode()
            # 可以直接“触发”相关事件,省去了很多麻烦
            self.bbcodeAction.trigger()
      else:
            # 转换为 Markdown
            self.toMarkdown()
            self.markdownAction.trigger()

    def toBBCode(self):
      """将输入框里的内容转换为 BBCode"""
      md = self.text.toPlainText().splitlines()
      res = ''
      # 0 表示没有特殊格式;1 表示有序列表;2 表示无序列表;3 表示代码块
      flag = 0
      for s in md:
            if not s:
                if flag in (1, 2):
                  # 结束有序/无序列表
                  res += '\n'
                  # 终于找到这个 bug 的源头了,结束之后程序还会尝试继续结束。。。
                  # 把下面这行加上就没事了
                  flag = 0
                # 空行
                res += '\n'
                continue
            if s[:3] == '1. ':
                # 进入有序列表
                res += '\n'
                flag = 1
            elif s[:2] in ('- ', '* ', '+ ') and flag != 2:
                # 进入无序列表
                res += '\n'
                flag = 2
            elif s[:3] == '```' and flag < 3:
                # 进入代码块(不换行)
                res += '[<避免被解析,请把这个删了>code]'
                flag = 3
                continue
            if flag == 1:
                # 有序列表
                s = re.sub(r'^\d+\. (.*)$', r'[*]\1', s)
            elif flag == 2:
                # 无序列表
                s = re.sub(r'^[-*+] (.*)$', r'[*]\1', s)
            elif flag == 3:
                # 代码块
                if s == '```':
                  # 可以离开代码块了
                  s = '[<避免被解析,请把这个删了>/code]'
                  flag = 0
                res += s + '\n'
                # 代码块期间啥都不能做(其实离开代码块的那一行也啥都不能做)
                continue
            # 标题的格式来源于 https://fishc.com.cn/thread-146275-1-1.html
            # 一级标题
            s = re.sub(r'^# (.*)$', r'\1', s)
            # 二级标题
            s = re.sub(r'^## (.*)$', r'\1', s)
            # 三级标题
            s = re.sub(r'^### (.*)$', r'\1', s)
            # 引用
            s = re.sub(r'^> (.*)$', r'\1', s)
            # 非贪婪匹配:'*1* *2*' 会替换成 '1 2' 而不是 '1* *2'
            # 加粗
            s = re.sub(r'\*\*(.*?)\*\*', r'\1', s)
            # 斜体
            s = re.sub(r'\*(.*?)\*', r'\1', s)
            # 行内代码
            s = re.sub(r'`(.*?)`', r'\1', s)
            # 删除线
            s = re.sub(r'~~(.*?)~~', r'\1', s)
            # 图片
            s = re.sub(r'!\[.*?]\((.*?)\)', r'\1', s)
            # 链接
            s = re.sub(r'\[(.*?)]\((.*?)\)', r'\1', s)
            res += s + '\n'
      if flag in (1, 2):
            # 还没结束有序/无序列表
            res += '\n'
      res = re.sub(r'\n{3,}', '\n\n', res)
      self.text.setPlainText(res)

    def toMarkdown(self):
      """将输入框里的内容转换为 Markdown"""
      bbcode = self.text.toPlainText().splitlines()
      res = ''
      # 0 表示没有特殊格式;1 表示有序列表;2 表示无序列表;3 表示代码块;4 表示引用
      flag = 0
      # 有序列表的索引
      index = 0
      for s in bbcode:
            if not s:
                # 空行
                res += '\n'
                continue
            if s == '':
                # 进入有序列表
                flag = 1
                continue
            elif s == '':
                # 进入无序列表
                flag = 2
                continue
            elif s == '':
                # 退出有序/无序列表
                flag = 0
                continue
            elif s[:5] == '[<避免被解析,请把这个删了>code]':
                # 进入代码块
                res += '```\n'
                flag = 3
            elif s[:7] == '' or (flag == 4):
                # 引用区域
                s = re.sub(r'^(\)?(.*)$', r'> \2', s)
                flag = 4
            # 可以在一行的末尾退出代码块和引用
            if s[-7:] == '[<避免被解析,请把这个删了>/code]':
                s = s[:-7]
                flag = 0
            elif s[-8:] == '':
                s = s[:-8]
                flag = 0
            if flag == 1:
                # 有序列表
                s = re.sub(r'^\[\*] (.*)$', fr'{index}. \1', s)
            elif flag == 2:
                # 无序列表
                s = re.sub(r'^\[\*] (.*)$', r'- \1', s)
            elif flag == 3:
                # 代码块
                res += s + '\n'
                # 代码块期间啥都不能做(其实离开代码块的那一行也啥都不能做)
                continue
            # 不需要处理引用
            # 一级标题
            s = re.sub(r'^\\\(.*)\\\$', r'# \1', s)
            # 二级标题
            s = re.sub(r'^\\(.*)\\$', r'## \1', s)
            # 三级标题
            s = re.sub(r'^\\\(.*)\\\$', r'### \1', s)
            # 加粗
            s = re.sub(r'\(.*?)\', r'**\1**', s)
            # 斜体
            s = re.sub(r'\(.*?)\', r'*\1*', s)
            # 行内代码
            s = re.sub(r'\(.*?)\', r'`\1`', s)
            # 删除线
            s = re.sub(r'\(.*?)\', r'~~\1~~', s)
            # 图片
            s = re.sub(r'!\(.*?)\', r'![](\1)', s)
            # 链接
            s = re.sub(r'\(.*?)\', r'[\2](\1)', s)
            res += s + '\n'
      res = re.sub(r'\n{3,}', '\n\n', res)
      self.text.setPlainText(res)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())


-- 没了 --

感谢支持!

不二如是 发表于 2025-4-17 22:27:20

{:10_256:}{:10_256:}{:10_256:}好项目

player-none 发表于 2025-4-17 22:40:58

不二如是 发表于 2025-4-17 22:27
好项目

感谢支持{:10_298:}

小甲鱼 发表于 2025-4-18 02:02:42

不错呀!

ydwb 发表于 2025-4-21 06:57:07

感谢分享

player-none 发表于 2025-4-24 21:26:17

{:10_324:}竟然冷了

liuhongrun2022 发表于 2025-4-26 10:42:41

这个新人好厉害

player-none 发表于 2025-4-26 21:26:38

liuhongrun2022 发表于 2025-4-26 10:42
这个新人好厉害

一本正经的胡说八道
页: [1]
查看完整版本: 【自制】BBCode/Markdown Converter