从 C# WinForms 到 Flask + SQLite:期刊出版 XML 生成器(JATS)重构实践

从 C# WinForms 到 Flask + SQLite:期刊出版 XML 生成器(JATS)重构实践

一个把 Word 稿件一键转成 JATS XML 的小工具,以及把它从桌面端重构成 Web 端的完整过程。

一、背景:编辑部为什么需要它

JATS(Journal Article Tag Suite)是 NISO 制定的期刊文章标记标准,PubMed Central、SciELO、Crossref 这些收录平台都要求以 JATS XML 作为提交格式。

但国内不少期刊编辑部的实际工作流是这样的:

  1. 作者交来 Word 稿件;
  2. 编辑按内部约定排好格式;
  3. 需要向收录库提交时,再由人手工把期刊名、ISSN、DOI、卷期页、作者单位、参考文献等一项项敲进 XML 模板。

问题很直观------同一份信息被录入两遍甚至三遍,重复劳动,且极易出错(参考文献格式尤其容易写歪)。

需求最终收敛成一句话:

给编辑部一个工具,丢进一份「按约定格式写的 Word 稿件」,直接吐出一份合规的 JATS XML。

最初的实现是一套 C# WinForms 桌面程序(src/XmlGenerator),功能是完整的,但落地时有几个现实障碍:

  • 只能在 Windows 上装 。程序依赖 .NET Framework 和 DocumentFormat.OpenXml 等一堆组件,还要处理 Office 互操作 DLL;
  • 数据存在 SQL Server 里。为了一个小工具去装数据库,运维成本不成比例;
  • 界面难改 。WinForms 是拖拽设计器,改一处布局要动 FrmMain.Designer.cs------光是这个文件就有 68 KB;
  • 每位编辑都得装一遍。升级一次就要全办公室重装。

于是决定重构成 Flask + SQLite 的 Web 版 :浏览器打开就用,数据落在本地一个 .db 文件里,客户端零安装。

二、它长什么样

界面是「左中右」三栏:左侧是操作步骤提示,中间是分 8 组的结构化表单,右侧是实时刷新 的 XML 预览。

往下滚动是作者、出版信息、版权与许可、正文章节几组表单:

参考文献这一组值得一提------引文不是一坨纯文本,而是被拆成了作者、标题、期刊、年、卷、期、起始页、结束页 8 个结构化字段,这样生成 XML 时才能按 JATS 的要求重新拼装:

三、技术选型:为什么是 Flask + SQLite + 原生 JS

这个工具的核心资产是解析规则和 XML 结构 ,不是并发能力、不是高可用。所以选型标准只有三条:部署简单、逻辑好移植、界面好改。

层面 选型 备选 取舍理由
Web 框架 Flask Django / FastAPI 总共 11 个接口,Flask 够用且代码最少;Django 的 ORM/Admin 属于杀鸡用牛刀
数据库 SQLite SQL Server / MySQL 单机单用户,一个文件即数据库,拷走就能搬;表结构仍对齐原 C# 版
ORM Flask-SQLAlchemy 裸 sqlite3 5 张表有级联删除和排序需求,ORM 描述关系更清晰
Word 解析 python-docx 调 Word COM / OpenXml SDK 纯 Python、跨平台、无需装 Office,只读段落文本完全够用
XML 生成 手写轻量生成器 lxml / ElementTree 见第六节,手写反而更可控
前端 原生 JS Vue / React 无构建、无依赖,单页三个文件,双击 index.html 就能改

一句话总结:把「重」的部分全部留在服务端的 40 KB Python 里,前端保持零依赖。

四、代码结构

复制代码
xmlgenerator/
├── app.py              # Flask 路由层:文档 CRUD / 上传解析 / 预览 / 下载 / 字段配置
├── word_parser.py      # Word 解析器:标签行 → 字段、章节标题、作者、参考文献
├── jats_builder.py     # JATS XML 生成器:front / body / back
├── models.py           # 数据模型:doc / author / section / doc_ref / doc_config
├── requirements.txt    # 三个依赖:Flask、Flask-SQLAlchemy、python-docx
├── templates/index.html
├── static/{css,js}     # style.css / app.js / i18n.js
├── data/word/          # 上传的 Word 原件
├── data/xml/           # 生成的 XML 产物
└── jats.db             # SQLite 数据库

后端 4 个文件共 40,118 字节(约 40 KB),前端 4 个文件共 48,772 字节(约 48 KB)。对比之下,C# 版的纯逻辑代码 (19 个 .cs,不含设计器文件)是 135,238 字节(约 132 KB),若把 *.Designer.cs 一起算上是 232,715 字节(约 227 KB)。

模块 职责 对应的 C# 类
app.py HTTP 接口、文件上传下载、会话与事务边界 FrmMain.cs(事件处理部分)
word_parser.py 从 docx 提取结构化数据 WordDocReader_Regex.cs + ArticleField_*.cs
jats_builder.py 数据 → JATS XML 字符串 JournalArticleXmlGenerator_2.cs
models.py 表结构与 DTO 转换 Model.Doc / author / Section / Doc_Ref / DocConfig

五、核心实现一:Word 解析

Word 稿件里没有任何语义标记,只有一堆段落文本。所以解析策略是**「约定 + 三遍扫描」**:

约定 :稿件里用 字段名: 值 形式的标签行承载元数据,例如:

复制代码
Journal Title: Journal of Advanced Science
DOI: 10.1234/jas.2026.1001
Article Title: Deep Learning Approaches for Automated Journal Article Structuring
Keywords: deep learning; JATS XML; scholarly publishing
Author: Wang Lei (Department of Computer Science, XX University)
Reference: [1] Smith J, Doe A. Article title. J Sci. 2024;12(3):45-56.
1 Introduction
1.1 Background

全文按段落取出后,分三遍各管一件事。

5.1 第一遍:标签行 → 字段(可配置的别名映射)

关键设计是别名映射表------字段叫什么由数据库里的配置决定,不写死在代码里:

python 复制代码
RE_LABEL = re.compile(r'^\s*([^::]{2,30})[::]\s*(.*)$')

def _match_field_label(text, alias_map):
    """判断段落是否为「字段别名: 值」格式的标签行,返回 (字段名, 值) 或 None"""
    m = RE_LABEL.match(text)
    if not m:
        return None
    label = m.group(1).strip().lower()
    value = m.group(2).strip()
    field_name = alias_map.get(label)
    if not field_name:
        return None
    return field_name, value

alias_map 由 DocConfig 表构建,一个字段可以挂多个别名:

python 复制代码
DEFAULT_FIELD_CONFIGS = [
    ('Article_Title', 'article title,title,文章标题,标题,题目'),
    ('Abstract',      'abstract,摘要'),
    ('KeywordList',   'keywords,keyword,关键词'),
    # ... 共 20 个字段
]

这个「在线可配」的设计是重构时才加的,价值比想象中大------不同期刊的稿件约定不一样,有的写 Abstract,有的写 摘要,有的写 中文摘要。以前这些差异要改代码重新编译,现在打开弹窗加个别名就行。

标签行之后、下一个标签行之前的内容会继续追加到当前字段,因此多行摘要、多段致谢都能正确收集:

python 复制代码
for text in paragraphs:
    matched = _match_field_label(text, alias_map)
    if matched:
        current_field, value = matched
        _set_field(dto, _FIELD_COLUMNS, current_field, value)
        continue
    # 非标签行:继续追加到当前字段(多行摘要/致谢等)
    if current_field and current_field not in ('Author', 'Reference'):
        _append_field(dto, _FIELD_COLUMNS, current_field, text)

5.2 第二遍:章节标题识别

章节靠编号模式识别,两级:

python 复制代码
RE_SEC_H1 = re.compile(r'^(\d+)\s+(.+)$')          # 1 引言
RE_SEC_H2 = re.compile(r'^(\d+)\.(\d+)\s+(.+)$')   # 1.1 小节

注意顺序 :必须先匹配二级再匹配一级。因为 ^(\d+)\s+(.+)$ 里 \s+ 要求编号后是空白,1.1 小节 的 1. 后面跟着 1 而不是空白,理论上不会误匹配------但把二级放前面更保险,也让意图更明确。

5.3 第三遍:作者与参考文献的「区域收集」

作者和参考文献是多段落区域,不是单行标签。所以先定位区域边界,再交给专门的解析器:

边界规则是:遇到其他字段的标签行 结束当前区域;遇到单独成行的 References / 参考文献 则切换到文献区域。

python 复制代码
def _collect_zone(paragraphs, alias_map, field_name):
    zone, collecting = [], False
    for text in paragraphs:
        matched = _match_field_label(text, alias_map)
        if matched:
            if collecting and matched[0] != field_name:
                break                      # 遇到下一个字段标签行,区域结束
            collecting = matched[0] == field_name
            if collecting and matched[1]:
                zone.append(matched[1])
            continue
        if RE_REF_BOUNDARY.match(text):    # 单独成行的 References
            collecting = (field_name == 'Reference')
            continue
        if collecting:
            if RE_SEC_H1.match(text) and field_name != 'Reference':
                break                      # 作者区遇到章节标题即结束
            if RE_SEC_H1.match(text) and field_name == 'Reference' and not RE_REF_NO.match(text):
                break                      # 文献区只被「非序号行」的章节标题打断
            zone.append(text)
    return zone

最后那个 and not RE_REF_NO.match(text) 是有意为之:参考文献区域里本来就可能出现 1. Smith J... 这种以数字编号开头的条目,如果把章节标题规则直接套上去,会把第 1 条文献误判成章节标题而截断整个区域。

作者解析支持两种写法,并且姓/名的拆分对中西文分开处理:

python 复制代码
def _split_name(name):
    """拆分姓名为姓/名:中文取首字为姓;西文取末词为姓"""
    name = re.sub(r'\s+', ' ', name).strip()
    if re.search(r'[\u4e00-\u9fff]', name):
        return (name[0], name[1:]) if len(name) > 1 else (name, '')
    parts = name.split(' ')
    return (parts[-1], ' '.join(parts[:-1])) if len(parts) > 1 else (name, '')

Wang Lei → surname=Wang, given=Lei;张三 → surname=张, given=三。这是为了对齐 JATS 的 <name name-style="western"><surname>..</surname><given-names>..</given-names></name> 结构。

5.4 参考文献拆解:顺序很关键

一条文献长这样:

复制代码
[1] Smith J, Doe A. Article title. J Sci. 2024;12(3):45-56.

要拆成 8 个字段,必须先摘出「年;卷(期):页」这一段,剩下的再按句点切:

python 复制代码
seg_re = re.compile(r'(\d{4})\s*;\s*(\d+)\s*(?:\(([^)]+)\))?\s*:\s*(\d+)\s*-\s*(\d+)')
m = seg_re.search(body)
if m:
    ref['year'], ref['volume'], ref['issue'] = m.group(1), m.group(2), m.group(3) or ''
    ref['fpage'], ref['lpage'] = m.group(4), m.group(5)
    body = body[:m.start()].strip().rstrip('.,') + body[m.end():].strip().rstrip('.,')
    body = body.strip('. ').strip()

sentences = [s.strip() for s in body.split('.') if s.strip()]
if sentences:
    ref['journal'] = sentences[-1]            # 最后一段是期刊名
    if len(sentences) >= 3:
        ref['authors'] = sentences[0]
        ref['title'] = '. '.join(sentences[1:-1])
    elif len(sentences) == 2:
        ref['title'] = sentences[0]

如果反过来先按句点切,2024;12(3):45-56 里的句点、以及标题里可能出现的缩写点,都会把结构打乱。先摘走结构最固定的一段,再处理自由文本------这个思路在很多「半结构化文本抽取」场景都适用。

5.5 解析失败要给提示,不要静默

解析结果连同 warnings 一起返回,界面上以 toast 提示:

python 复制代码
if not any(dto[k] for k in _FIELD_COLUMNS.values()):
    warnings.append('未从 Word 中识别到任何元数据字段,请检查文档是否含「字段名: 值」格式的标签行,'
                    '或在「字段配置」中调整别名')
if not dto['sections']:
    warnings.append('未识别到章节标题(需形如「1 引言」的编号段落)')
if not dto['references']:
    warnings.append('未识别到参考文献(需形如「[1] ...」的段落)')

这一步很朴素,但直接决定了好不好用。解析器面对的是千奇百怪的稿件,「没解析出来」和「解析错了」必须让用户看得见,否则他们会以为工具坏了,而实际上是稿件格式不符约定------有了提示,他们自己就能对照着改。

六、核心实现二:JATS XML 生成器

输出结构严格对齐原 C# 版,也符合 JATS Journal Publishing DTD v2.3:

复制代码
article
├── front
│   ├── journal-meta   (journal-id / journal-title / issn / publisher)
│   └── article-meta   (article-id / categories / title-group / contrib-group /
│                       volume / issue / fpage / lpage / permissions /
│                       abstract / kwd-group / counts)
├── body
│   └── sec × N        (title + p × N)
└── back
    ├── ack
    └── ref-list
        └── ref → mixed-citation

6.1 用 with 语法管理缩进

XML 是本工具的最终交付物,缩进和换行必须稳定(人要审阅、要 diff、要入库)。与其引入 lxml 再配置序列化参数,不如手写一个 20 行的缩进管理器:

python 复制代码
class _XmlGen:
    """轻量 XML 生成器:维护缩进层级,支持 with 语法嵌套"""

    def __init__(self):
        self._lines = []
        self._depth = 0
        self._indent = '  '

    class _Scope:
        """with 作用域:进入加深缩进,退出时输出对应的闭合标签"""
        def __init__(self, gen, name):
            self._gen = gen
            self._name = name

        def __enter__(self):
            self._gen._depth += 1
            return self

        def __exit__(self, *exc):
            self._gen._depth -= 1
            self._gen._lines.append('%s</%s>' % (self._gen._pad(), self._name))
            return False

    def el(self, name, attrs=None):
        self._lines.append('%s<%s%s>' % (self._pad(), name, self._attrs(attrs)))
        return _XmlGen._Scope(self, name)

    def leaf(self, name, text, attrs=None):
        self._lines.append('%s<%s%s>%s</%s>'
                           % (self._pad(), name, self._attrs(attrs), escape(str(text)), name))

用起来就变成了「所见即所得」的结构描述------代码的缩进层级和 XML 的输出层级完全一致,一眼就能看出生成的文档长什么样:

python 复制代码
def _build_front(g, doc):
    with g.el('front'):
        with g.el('journal-meta'):
            if _v(doc.get('journalTitle')):
                g.leaf('journal-id', journal_title, {'journal-id-type': 'nlm-ta'})
                g.leaf('journal-title', journal_title)
            if _v(doc.get('issnPpub')):
                g.leaf('issn', doc.get('issnPpub'), {'pub-type': 'ppub'})
            # ...
        with g.el('article-meta'):
            if _v(doc.get('doi')):
                g.leaf('article-id', doc.get('doi'), {'pub-id-type': 'doi'})
            # ...

转义直接用标准库:xml.sax.saxutils.escape 处理文本、quoteattr 处理属性------自己拼字符串时最容易漏掉的就是转义,直接借标准库就绕开了这个坑。

6.2 枚举值必须校验,非法值静默降级

article-type 是受控词表,值写错会让下游解析失败:

python 复制代码
ARTICLE_TYPES = {
    'research-article', 'review-article', 'case-report', 'brief-report',
    'editorial', 'letter', 'correction',
}

article_type = doc.get('articleType') or 'research-article'
if article_type not in ARTICLE_TYPES:
    article_type = 'research-article'

宁可用一个合法的兜底值,也不要生成一份看起来没报错、但下游解不开的 XML。

6.3 参考文献引文的反向拼装

存入时是 8 个字段,输出时按 authors. title. journal. year;vol(issue):fpage-lpage. 拼回一行。每一步都要处理「缺项」------卷号可能没有、期号可能没有、结束页可能没有:

python 复制代码
def _ref_text(ref):
    parts = []
    for key in ('authors', 'title', 'journal'):
        v = _v(ref.get(key))
        if v:
            parts.append(v if v.endswith('.') else v + '.')

    meta = []
    year = _v(ref.get('year'))
    vol = _v(ref.get('volume'))
    issue = _v(ref.get('issue'))
    fpage = _v(ref.get('fpage'))
    lpage = _v(ref.get('lpage'))
    if year:
        seg = year
        if vol:
            seg += ';' + vol + (('(%s)' % issue) if issue else '')
        if fpage:
            seg += ':' + fpage + (('-' + lpage) if lpage else '')
        meta.append(seg)

    citation = ' '.join(parts)
    if meta:
        citation = (citation + ' ' if citation else '') + ' '.join(meta)
    if citation and not citation.endswith('.'):
        citation += '.'
    return citation.strip()

v if v.endswith('.') else v + '.' 这一行是为了不产生 J Sci.. 这种双点------拼接类逻辑里,「标点归属」永远是最容易出细节 bug 的地方。

七、核心实现三:数据模型

原 C# 版的库结构是 5 张表,重构时表结构照搬,只把 SQL Server 换成 SQLite:

表 说明
doc 文档主表,约 25 个元数据字段
author 作者(surname / given-names / affiliation / sort_id)
section 章节(title / desp / sort_id)
doc_ref 参考文献(8 个结构化字段 + sort_id)
doc_config 字段别名配置(doc_id 为空 = 全局模板)

两个细节值得记下来。

一是主键格式 。SQL Server 用的是 uniqueidentifier,Python 侧用 32 位无连字符 GUID 对齐:

python 复制代码
def new_guid():
    """生成 32 位无连字符 GUID,与 SQL Server uniqueidentifier 对应"""
    return uuid.uuid4().hex

这样迁移数据时不需要做 ID 转换。

二是级联删除 。删文档要连带删掉它的作者、章节、参考文献,交给 ORM 声明式处理,比手写三条 DELETE 可靠:

python 复制代码
authors = db.relationship('Author', backref='doc', cascade='all, delete-orphan',
                          order_by='Author.sort_id', lazy='joined')

order_by='sort_id' 保证顺序稳定------表单里作者是按行排列的,数据库取出来顺序变了就全乱了;lazy='joined' 则避免了 N+1 查询。

三是保存语义 。文档保存时子表采用全量覆盖(清空后重建),这在单用户编辑场景下比逐条 diff 简单得多,也不容易出「删了一行但数据库还留着」的幽灵数据:

python 复制代码
def _replace_children(doc, data):
    """全量重建作者/章节/参考文献子表记录"""
    doc.authors.clear()
    for i, a in enumerate(data.get('authors') or []):
        doc.authors.append(AuthorRow(a, i))
    # sections / references 同理

八、前端:零构建的三栏界面

前端没有用任何框架,四个文件、不到 50 KB。这个体量下引入构建工具反而是负担。

几个实现上的取舍:

api() 统一封装 ,顺带把非 2xx 转成异常,业务代码里就不用每次判断 resp.ok:

javascript 复制代码
async function api(url, options = {}) {
  const opts = { headers: {}, ...options };
  if (opts.body && typeof opts.body !== 'string') {
    opts.body = JSON.stringify(opts.body);
    opts.headers['Content-Type'] = 'application/json';
  }
  const resp = await fetch(url, opts);
  const data = await resp.json().catch(() => ({}));
  if (!resp.ok) throw new Error(data.error || resp.statusText);
  return data;
}

实时预览要做防抖 。表单输入触发 input 事件,如果每次都打后端,打字过程中会连发几十个请求:

javascript 复制代码
const scheduleRender = debounce(() => renderPreview(), 500);

500 ms 的延迟在体感上仍然是「边打边变」,但请求量降了一个数量级。

XML 高亮用轻量正则。不引入 highlight.js(几十 KB 只为渲染一种语言不划算):

javascript 复制代码
function highlightXml(xml) {
  let s = esc(xml);
  s = s.replace(/(<\/?)([\w.:-]+)/g, '$1<span class="tag">$2</span>');
  s = s.replace(/([\w.:-]+)=("[\s\S]*?")/g, '<span class="attr">$1</span>=<span class="val">$2</span>');
  return s;
}

动态行是「全量收集 / 全量回填」。作者、章节、参考文献的行数不固定,最简单的做法是每次从 DOM 里把所有行读一遍:

javascript 复制代码
dto.authors = $$('#authorList .row').map((row) => ({
  surname: $('.a-surname', row).value.trim(),
  given: $('.a-given', row).value.trim(),
  aff: $('.a-aff', row).value.trim()
}));

代价是每次读取都要遍历 DOM,但总共几十行数据,完全无感;换来的是没有中间状态,不用维护「哪一行对应哪个对象」。

中英双语 用 data-i18n 属性 + 字典查表,语言偏好存 localStorage:

javascript 复制代码
$$('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n); });

这里踩了一个坑:切换语言时如果直接重渲染 DOM,动态行里用户填的内容会丢。修法是切换前先 collectData() 存一份,切完再按新语言重建:

javascript 复制代码
$('#btnLang').addEventListener('click', () => {
  const data = collectData();          // 切换前保存动态行内容
  lang = lang === 'zh' ? 'en' : 'zh';
  applyI18n();
  $('#authorList').innerHTML = '';
  data.authors.forEach((a) => addAuthorRow(a));
  // ...
});

九、踩坑记录

1)全角标点。 中文输入法打出来的 :「;」「,」跟半角不是一个字符。标签行正则里 [^::] 两种冒号都要收,关键词拆分要把 ;;,, 全部归一化成 ;:

python 复制代码
for ch in ';;,,':
    text = text.replace(ch, ';')

2)别名匹配前必须手工归一化。 数据库里存的别名大小写不统一,正则 re.IGNORECASE 管不到字典查表这一步,所以统一 label.strip().lower(),别名入库时也统一 lower:

python 复制代码
return {c.field_name: [a.strip().lower() for a in (c.aliases or '').split(',') if a.strip()]
        for c in configs}

3)「标签行后面没有值」是合法状态。 Abstract: 单独一行,值在下一段------所以匹配成功但值为空时不能当作没匹配,要继续让后续段落往里追加:

python 复制代码
# 标签行后无值也算匹配(值在后续行)
return field_name, value

4)正则多分支时,取的组号要对。 参考文献序号正则有两个分支:

python 复制代码
RE_REF_NO = re.compile(r'^\[(\d+)\]\s*(.*)$|^\(?(\d+)\)?[.、]\s*(.*)$')

第一个分支的编号和正文是 group(1) / group(2),第二个分支是 group(3) / group(4)。写的时候很自然地会去取 group(1),结果第二种写法下永远拿到 None:

python 复制代码
no = m.group(1) or m.group(3) or str(len(references) + 1)
body = (m.group(2) or m.group(4) or '').strip()

5)区域边界规则的「例外」要单独写。 前面提过,文献区域不能被 1. Smith J... 这种编号条目打断------这个例外如果不显式写出来,表现为「参考文献只解析出第一条」,而且很难看出原因。

6)空值要一路防御。 从 Word 解析出来的字段可能全是空字符串、也可能是 None。所有取值都过一层:

python 复制代码
def _v(val):
    """None 安全的 strip"""
    return val.strip() if isinstance(val, str) else ''

比在每个使用点写 if x: 要省心得多。

7)下载文件名要洗一遍。 文章标题里可能有 : / ? 这类在 Windows 上非法的字符:

python 复制代码
filename = ''.join(c for c in name if c not in '\\/:*?"<>|')[:60] + '.xml'

顺手做了 60 字符截断,避免超长文件名。

8)上传要做格式与体积双重校验。 只收 .docx(python-docx 读不了老版 .doc),并在 Flask 配置里限制 32 MB:

python 复制代码
app.config['MAX_CONTENT_LENGTH'] = 32 * 1024 * 1024  # 上传上限 32MB

提示语也要写清楚怎么办------「仅支持 .docx 格式(请先用 Word 另存为 .docx)」,比单纯说「格式错误」有用得多。

9)开发期关掉静态文件缓存。 不然改了 JS 刷新页面没反应,会浪费很多时间去怀疑代码:

python 复制代码
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0  # 开发期禁用静态文件缓存

十、重构前后对比

维度 C# WinForms 版 Flask 版
部署 装 .NET + 一堆 DLL,每台机器装一次 pip install -r requirements.txt + python app.py,浏览器打开
数据库 SQL Server 实例 单个jats.db 文件,拷走即搬
界面改动 拖设计器,FrmMain.Designer.cs 68 KB 改 HTML/CSS,即时生效
Word 解析 WordDocReader_Regex + 多个 ArticleField_* word_parser.py 单文件
字段别名 改代码后重新编译 界面上在线改,存库即时生效
跨平台 仅 Windows Windows / Linux / macOS 均可
数据外发 --- 全程本机,不联网

代码规模上,核心逻辑从约 132 KB C# 降到约 40 KB Python,前端另加约 48 KB(零依赖)。

十一、小结

几点感受:

1. 重构不是重写,是「翻译 + 减负」。 这次重构的价值不在于换了技术栈,而在于把原版里耦合在 WinForms 事件处理中的业务逻辑拆成了三个职责清晰的模块(解析 / 生成 / 存储)。拆开之后,每个文件都短到能一口气读完,加功能和改 bug 的成本都降下来了。

2. 「可配置」优先于「聪明」。 最初想过做更智能的 Word 结构推断,最后选择了「约定 + 可配置别名」这条路。原因是期刊排版约定本来就是人定的、而且是会变的,把变化点暴露成配置项,比写一堆猜不准的启发式规则更实用。上线后果然------不同刊物的稿件差异基本都是靠加别名解决的。

3. 解析类工具,错误提示和解析能力同样重要。 工具面对的是格式不受控的输入,不可能 100% 命中。能把「为什么没解析出来」讲清楚,用户就能自己修稿件;讲不清楚,工具再准也会被放弃。

4. 选中合适的技术而不是先进的技术。 一个单机单用户、日处理量个位数的小工具,用 Flask + SQLite 是最贴合的答案。这个判断在写第一行代码之前就该做完。

后续可以做的

  • 在线校对预览:左侧表单与右侧 XML 双向定位(点 XML 节点跳到对应表单项);
  • 批量处理:选一个文件夹,把所有 docx 批量转成 XML 并打包下载;
  • 导出模板:把一份配置好的文档存成模板,新稿件只填差异部分;
  • 校验:接入 JATS DTD / XSD 做生成后的结构校验,出问题直接顶上红标。

技术栈:Python 3 / Flask 3 / Flask-SQLAlchemy / SQLite / python-docx / 原生 JavaScript

输出标准:JATS Journal Publishing DTD v2.3

相关推荐
半亩码田2 小时前
C#转Python第4.7篇:logging:Python 的标准日志库
开发语言·python·c#
xhy_07072 小时前
AI 读了 .env,密钥会原样发给大模型吗?WES Code 的密钥打码(附实测)
人工智能·python·数据挖掘·flask·ai编程·wes code
宝桥南山2 小时前
.NET 11 - 尝试创建一下AGUIServer和AGUIClient(基于新版.NET SDK for AG-UI)
microsoft·ai·微软·c#·aigc·.net
czhc11400756632 小时前
加在入口还是出口 —— 以及一个“看着像文件夹“的文件
c#
波力海苔夹心脆6753 小时前
C# LINQ 入门到上手:一篇讲透查询语法、常用操作符与延迟执行(含示例与速查表
经验分享·c#·.net·solr·linq
进击的大海贼1 天前
基于C#开发的久坐提醒工具
开发语言·c#
vx_Biye_Design1 天前
flask学生课程笔记共享系统29026-计算机课程设计、毕业设计
java·javascript·spring boot·后端·elasticsearch·flask·课程设计
小羊没烦恼!1 天前
关于大型asp.net应用系统的架构-架构的选择
java·服务器·开发语言·前端·c#