# lheescore_cpp **Repository Path**: q568680472/lheescore_cpp ## Basic Information - **Project Name**: lheescore_cpp - **Description**: lheescore 五线谱排版引擎 java 移植到 cpp 版本 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-09-19 - **Last Updated**: 2026-09-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 五线谱排版引擎 C++ 版(com::luhe::cpp) 这是 `com.luhe.score` 五线谱排版引擎的 **1:1 标准 C++ 移植**,与 Java 版目录一一对应,由通过测试: | Java(`src/com/luhe/score/...`) | C++(`src/com/luhe/cpp/...`) | |---|---| | `data.musiccode.code.*` | `data/musiccode/code/*` | | `data.musiccode.alg.*` | `data/musiccode/alg/*` | | `render.config.*` | `render/config/*` | | `render.symbol.*` | `render/symbol/*` | | `render.group.*` | `render/group/*` | | `util.*` | `util/*` | | (Java 用 Batik 的 SVG DOM) | `xml/*`(自建迷你 XML/SVG DOM,无第三方依赖) | 命名空间根为 `com::luhe::cpp`,子命名空间与目录同构,例如 Java 的 `com.luhe.score.render.symbol.piano.notation.staff.StaffSymbol` 对应 C++ 的 `com::luhe::cpp::render::symbol::piano::notation::staff::StaffSymbol`。 ## 目录结构:每个类一个 .cpp,没有 .hpp 已经把所有 `.hpp` 合并进 `.cpp`,现在**只有 .cpp,没有头文件**,方法和原来写在 头文件里的声明一样,全部写在类体内(像 Java 那样): ``` render/symbol/Symbol.cpp class Symbol { ... void calSize() override { ... } ... }; render/symbol/piano/.../FiveLineSymbol.cpp class FiveLineSymbol : public Symbol { ... }; lhee_all.cpp (自动生成的聚合编译单元,见下) main.cpp 命令行入口 ``` 规模:**123 个 .cpp,0 个 .hpp,约 9400 行**(合并前是 121 个 .hpp + 94 个 .cpp、约 10500 行)。 ### 为什么只编译一个 .cpp 头文件消失之后,「另一个类」的完整定义只能靠 `#include "那个类的.cpp"` 拿到。 但原始的 include 图是 DAG 只是因为 **.hpp 从不 include .cpp**;把 .hpp 并进 .cpp 之后,边变成 `.cpp -> .cpp`,出现了两个真正的环: ``` FiveLineSymbol -> StaffSymbol -> NoteSymbol -> FiveLineSymbol (还有 KeySymbol / MeasureSymbol) CommonNoteSymbol -> DotSymbol / NoteFlagSymbol -> CommonNoteSymbol ``` 光靠 include 顺序没法同时满足「A 要 B 完整、B 要 A 完整」,`#pragma once` 一定会把其中一个截断成不完整类型。所以改成: 1. 每个类仍是**独立的 .cpp**,保留原目录结构,仍用 `#include` 表达依赖; 2. 只编译 `main.cpp` 一个编译单元,它 `#include "lhee_all.cpp"`; 3. `lhee_all.cpp` 按**拓扑序**包含全部类实现文件(前 8 个是上面两个环的成员,顺序固定); 4. 环里**最少量的方法**改成类外定义,放在「它依赖的那个类」所在文件的末尾—— 到那里那个类已经完整了。一共只有 4 个方法: | 方法 | 定义放在 | 原因 | |---|---|---| | `FiveLineSymbol::calSize` | `staff/StaffSymbol.cpp` 末尾 | 与 `StaffSymbol` 互相依赖 | | `FiveLineSymbol::adjustPosition` | `staff/StaffSymbol.cpp` 末尾 | 同上 | | `StaffSymbol::adjustPosition` | `event/note/NoteSymbol.cpp` 末尾 | 与 `NoteSymbol` 互相依赖 | | `MeasureSymbol::eventAlign` | `event/note/NoteSymbol.cpp` 末尾 | 与 `NoteSymbol` 互相依赖 | 这 4 处在类里留了声明并注明「定义见 …末尾」,其余 **100% 的方法都写在类体内**。 副作用是好的:单编译单元 + 没有头文件重复解析,全量编译比原来快很多。 **新增一个类时**:照旧写 `…/Foo.cpp`,把类和方法都写在里面;然后在 `lhee_all.cpp` 里按依赖顺序加一行 `#include "…/Foo.cpp"`(依赖多的类排在后面即可,编译器会告诉你 顺序不对)。 ### 合并时踩到的坑(都已处理) - 匿名命名空间里的同名实体(`SVG_RESOURCE`/`cachedElement` 各 9 份、`trim` 2 份) 并进同一个 TU 会重定义 → 全部改成类内 `private static` 成员。 - 类外静态数据成员(`BeamGroupManager::makeBeamFlag`、`NotePositionUtil` 的两张表) → 类内 `inline static`(C++17),跨 TU 唯一实体。 - `SvgFileLoader` 的资源根目录原本是匿名命名空间里的全局变量,现在变成类的 `inline static` 成员,`setResourceRoot` 和 `load` 看到的是同一个对象。 - 匿名命名空间里的 `using event::note::NoteSymbol;` 之类声明,目标必须**先被声明**; 限定名的命名空间定义嵌在别的命名空间里 GCC 不会真的放到全局,所以这些前置声明 统一写在文件最外层。 - `PartSymbolCreator.cpp` 原来用 `__has_include(".../NotePositionUtil.hpp")` 探测头文件 存不存在来决定是否查表(决定音符纵向位置和上下加线)。合并后该 .hpp 不存在了, 探测必然为假、渲染结果会变,所以改成无条件 `#define CPP_SCORE_HAS_NOTE_POSITION_UTIL 1` (原始工程里该头文件一直存在,即宏一直为真)。 ### 等价性验证(合并前 vs 合并后) 同一个 `.code` 分别用合并前的 exe 和合并后的 exe 渲染,SVG **逐字节 SHA-256 对比**: | 验收项 | 结果 | |---|---| | 构建(`build.ps1` + CMake + MinGW GCC 9.2) | 通过,产出 `build/cpp_score_render.exe` | | 16 份可解析 `.code` 样例(`src/com/luhe/score/resource/code/*.code`)渲染结果 | **16/16 逐字节完全一致**(第 17 个 `c5_附点测试.code` 是空文件,两边都拒绝渲染) | ## 构建 要求:C++17 编译器 + CMake。本机已验证:MinGW GCC 9.2.0 + CMake 4.2.1。 ```powershell # 一键构建(自动把 MinGW 的 bin 加进 PATH) powershell -ExecutionPolicy Bypass -File src/com/luhe/cpp/build.ps1 # 或手工 cd src/com/luhe/cpp cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_CXX_COMPILER="C:/Program Files (x86)/Dev-Cpp/MinGW32/bin/g++.exe" cmake --build build ``` ## 运行 ```powershell # 读取 musiccode 文本,输出 SVG build/cpp_score_render.exe <输入.code> <输出.svg> [SVG资源目录] ``` ### SVG 符号资源目录 谱号 / 调号 / 拍号 / 符头 / 符尾 / 休止符这些图形片段,已经从 Java 仓库 `lheescore\src\com\luhe\score\resource\svg\output` 拷进本仓库: ``` resource/svg/output/ 18 个 svg(16 个实际被引用 + SixteenNoteFlag.svg 未被引用 + draft/NaturalNode.svg 里补过来的 NaturalNode.svg) ``` `util/SvgFileLoader.cpp` 按下面的顺序找它,先命中先用: | 顺序 | 候选 | 说明 | |---|---|---| | 1 | 命令行第 3 个参数 | 显式指定,优先级最高 | | 2 | `resource/svg/output`(相对当前目录) | 在仓库根目录直接跑就命中这条 | | 3 | `/resource/svg/output` | 双击 exe 时用 | | 4 | `/resource/svg/output` | IDE 跑 exe 时工作目录是 `build/`,用这条 | 第 3、4 条是为了「换个启动方式也能跑」:CLion/IntelliJ 启动程序时工作目录是构建目录, 直接双击 exe 时工作目录更不确定,只认当前目录的话会找不到资源、渲染出一堆空符号。 判定方式是探测候选目录下有没有 `TrebleClef.svg`。 ## 当前进度(分轮推进) ### 第 1 轮:端到端跑通 + 与 Java 引擎逐结构对齐(已完成) 移植范围:musiccode 数据模型、musiccode 解析器(预处理/声部/谱表)、完整符号层 (Symbol/基础图形 G/Line/Rect/Text/Circle/Ellipse/Path、标题、声部、行、小节、谱表、五线、 谱号/调号/拍号、全音符~三十二分音符 + 符头/符杆/符尾/附点/休止符/和弦/符杠/连音、 加线与音符位置映射 NotePositionUtil)、分组管理器(和弦/符杠/连音)、SVG 输出、命令行入口。 规模:218 个文件(121 头文件 + 94 实现),约 10500 行。 验收证据(本机实测,工具脚本在 `%TEMP%\cpprender`、`%TEMP%\cppparse`、`%TEMP%\cppfuzz`): | 验收项 | 结果 | |---|---| | 构建(`build.ps1` + CMake + MinGW GCC 9.2) | 通过,产出 `build/cpp_score_render.exe` | | 解析层逐字节对比(`src/com/luhe/score/resource/code/*.code`,16 个可解析样例) | **16/16 完全一致**(第 17 个 `c5_附点测试.code` 是空文件,Java 自己也抛异常) | | 渲染层结构对比(同一批样例,归一化去 xml 声明 / xmlns / Batik 默认属性的逐结点对比) | **16/16 完全一致** | | 解析层边界用例(多声部/附点/休止/连音/和弦/升降号/空小节/未知记谱法/行内注释,5 个) | 4/5 一致,唯一差异见下方“与 Java 的差异”第 1 条 | | 真实乐谱对比(从线上库 `webscore.score` 导出 22 份 `code`,含 8000 行级大谱) | **22/22 完全一致** | | 随机差分测试(随机生成多声部/多谱表/多小节/附点/休止/和弦/连音的 musiccode) | **250/250 一致,0 处差异** | | 像素级验证(把两边的 SVG 都用 Batik 转成 PNG 比对 SHA-256) | 小星星 / score45 / score53(13 MB SVG)**全部完全相同** | | 类覆盖审计(Java `render/**` 95 个类 ↔ C++ 头文件) | 双向 **0 缺失、0 多余** | | 桩函数审计(全树检索 TODO/未实现/占位) | 无 C++ 侧自造桩;命中的空实现都是 Java 原样的空方法 | ### 与 Java 版的差异(有意保留,均不影响渲染结果) 1. **解析器不保留 null 事件**:Java 的 `StaffParser` 会把解析不出来的音符串(例如单独一个 `h`) 作为 `null` 放进 `staff.eventList`;C++ 版直接跳过。所有消费方都做类型判断,渲染结果相同。 2. **更友好的错误处理**:Java 在若干输入上会抛异常(`PartParser` 的 `IndexOutOfBoundsException`、 `PartSymbolCreator` 里对未匹配拍号/空音符的 `NullPointerException`),C++ 版改为跳过该符号 或抛出带中文说明的异常(如“音符缺少八度数字:C”),其余逻辑保持一致。 3. **调号不渲染**:Java 的 `StaffParser` 解析 `key:X` 后没有把它放进 `eventList`, 因此调号符号永远不会生成;C++ 版 1:1 保留该行为(注释已标注)。 4. **未识别的记谱法**:Java 用 `null` 表示,C++ 用 `NotationType::UNKNOWN` 表示(语义等价)。 ### 已知遗留(Java 端同样存在,C++ 版 1:1 保留) - 升降号音名(`bD4`/`#F4`)查不到位置映射(映射表只含自然音),会走“无位置”分支; 且 `Note.getMidi()` 会把带前缀的音名当默认音级处理,黑键音高会差一个半音。 - **音符级的变音记号(`#`/`b`/`n`)完全不画**:解析出来的 `Note::alter` 只喂给 `Note::getMidi()`,渲染侧没有任何地方为音符生成 `SharpSymbol`/`FlatSymbol`/`NaturalSymbol`。 其中 `NaturalSymbol` 从未被实例化,是死代码(Java 版也是)。所以「还原号画不出来」 不是路径问题,而是这个功能没实现——`NaturalSymbol` 里那条写错的 `NaturalNode.svg` 路径已修正、资源也补齐到 `resource/svg/output/` 了,但要真画出来得补渲染逻辑 (做法见「后续轮次」第 5 条)。 - 简谱/六线谱(`jianpu:`/`tab:`)只识别不解析。 - 连音(tuplet)在时值累加时使用整数除法,实际加 0(C++ 与 Java 一致)。 - **同名谱表跨声部会被合并**:`MusicCodePreprocess` 按 `s1=` 这样的行名合并, 两个声部都用 `s1/s2` 时会被并成一行,小节数不一致时 Java 抛 `ArrayIndexOutOfBoundsException`(随机测试里 29 例即因此失败)。 写 musiccode 时请让谱表名全局唯一(如 p1 用 s1/s2、p2 用 s3/s4)。 - 未匹配的拍号(如 `time:6/8`,没有对应的 `TimeSymbol` 子类)会让 Java 抛 `NullPointerException`;C++ 版跳过该拍号继续渲染并在控制台留痕。 - 空小节里混入解析失败产生的 `null` 音符时 Java 同样抛 `NullPointerException`;C++ 版跳过。 ## 后续轮次(候选,按需选择) 1. **播放事件层**:移植 `render/symbol/...` 之外的 `EventTickCreator`(musiccode → 播放事件, 对应线上 `/api/score/event/{id}`),让 C++ 版也能产出播放/高亮数据。 2. **MIDI 导入**:移植 `data/midi/io/MidiToMusicCode`,让 C++ 版支持 midi → musiccode。 3. **可视化自检**:可选做一条“渲染 → PNG → 像素基线”的回归流水线(当前需要 Java+Batik 当转换器)。 4. **反向同步**:若以后把渲染改进(符杆 2px、斜向多边形符杠、每行重复谱号拍号、画布自适应) 加回 Java 版,C++ 版按同样的对比流程跟随即可(验收脚本已就绪)。 5. **音符级变音记号(# / b / n)**:目前完全没画(见「已知遗留」第一条)。要做的话大致是: - 在 `PartSymbolCreator` 造音符符号时读 `Note::alter`(`StaffParser` 已经解析好 +1/-1/0); - 造 `accidentals::SharpSymbol` / `FlatSymbol` / `NaturalSymbol`,挂到音符符号上; - 按谱表线号算纵向位置(`KeySymbol::staffLineY` / `placeAccidentals` 已有现成代码可抄), 放在符头左侧,并让记号宽度参与 `calSize` / 事件对齐,否则会和相邻音符重叠; - 资源侧已就绪:`SharpNode.svg` / `FlatNode.svg` / `NaturalNode.svg` 都在 `resource/svg/output/`,`NaturalSymbol` 里那条写错的路径也已修正。 ⚠️ 注意 `Note::getMidi()` 把带前缀的音名当默认音级处理(黑键音高差一个半音), 同时 `NotePositionUtil` 的映射表里只有自然音,所以黑键音名的纵向位置还得另外补表。 ## 如何自己复跑验收 对比思路:同一份 musiccode,分别让 Java 引擎与 C++ 引擎渲染成 SVG,再用一个“规范化器” (`xml::XmlParser` + 排序属性)把两边的 SVG 变成可逐行 diff 的文本,忽略 xml 声明、 xmlns、Batik 给根结点补的默认属性与缩进/属性顺序。 ```powershell # 1) 构建 C++ 引擎 powershell -ExecutionPolicy Bypass -File src/com/luhe/cpp/build.ps1 # 2) 仓库根目录下渲染任意一份 musiccode src/com/luhe/cpp/build/cpp_score_render.exe src/com/luhe/score/resource/code/c5_小星星.code out.svg ``` Java 侧基准(需要 batik-all、slf4j-api、xmlgraphics-commons、xml-apis-ext 四个 jar 在 classpath 上, 流程与 `TextUI.renderMusic()` 完全一致):用 `RenderTest.java` 渲染同一份输入, 再拿 `svg_canon.cpp` 规范化两个 SVG 后 diff;本机用过的脚本与产物在 `%TEMP%\cpprender\`(渲染对比、PNG 比对)、`%TEMP%\cppparse\`(解析层逐字节对比)、 `%TEMP%\cppfuzz\`(随机差分测试)。 注意:这些脚本都会把待测文件复制到 `%TEMP%` 下的临时文件再喂给两个引擎,脚本之间 **不要并行执行**(各自的临时文件名不同,但 Java 基准与 C++ 输出目录会互相覆盖)。 若并行跑出 FAIL,先单独重跑一遍确认是不是互相踩到了临时文件。 ## 备注 - `build.ps1` 带 UTF-8 BOM:Windows PowerShell 5.1 会按 ANSI 解码无 BOM 的 UTF-8 脚本, 中文注释里恰好出现的字节会把后面的换行“吃掉”,导致脚本执行失败。新增/修改该脚本时请保留 BOM。 - C++ 版命令行会打印 `[PartSymbolCreator] staff.name=...` 之类的过程日志(对应 Java 版 `java.util.logging` 的 info 输出),只影响控制台,不影响 SVG。 - 符号资源目录见上方「SVG 符号资源目录」:默认会自动探测,也可以显式 `SvgFileLoader::setResourceRoot()` 或用命令行第 3 个参数指定。 - **单个音符的升降还原号画不出来**(`#F4` / `bB3` / `nC4` 都一样):`StaffParser` 会把 前缀解析成 `Note::alter`(只用于 `Note::getMidi()` 算音高),但**没有任何代码为音符 生成变音记号符号**。`accidentals::NaturalSymbol` 更是从头到尾没有被 `make_shared` 过, 是死代码——Java 版同样如此。调号(`key:G` 等)的升降号是另一套逻辑(`*MajorKey`), 那个是正常的。详见上方「已知遗留」和「后续轮次」第 5 条。