SketchCAD 打印样式映射:黑白出图的线宽分组与灰度正交设计
摘要
黑白出图的正确性并非“把彩色转成灰色”这一简单操作所能保证。本文围绕 SketchCAD 打印样式映射的设计与实现,揭示“彩色线宽图打印必错”的真实因果链:颜色与线宽在屏幕上构成冗余信息载体,黑白输出使颜色维度归零,若线宽分组未按 GB/T 14665 显式给定,则仅存的线宽载体退化为图元自动派生值,分组效果失控。由此提出核心命题——灰度化只负责去掉颜色,线宽分组必须由样式映射表显式指定,二者正交。
在数据结构层面,本文给出“三维匹配 + 三正交属性”的样式条目模型、图层 > 颜色 > 线型的优先级短路语义、GB 预设按线型分粗 / 细两档、简化 CTB 行式文本格式与宽容解析策略;在打印集成层面,给出网点抖动仅作用于填充面、网点尺寸锚定设备空间、DPI 与 painter 生命周期对齐、纸张方向与版面尺寸分离等关键设计。验证方面,A4 / A3 双路回归以彩色像素计数、墨量变化、灰度直方图 L1 差异三项指标判定,彩色像素 0 vs 15471、墨量 +19.1%、L1 1.38% 三项读数共同证明:样式表真实改变了出图且黑白化彻底。
关键词:打印样式映射;CTB;GB/T 14665;线宽分组;灰度正交;抖动画刷;DPI 对齐;回归基线
文章目录
- SketchCAD 打印样式映射:黑白出图的线宽分组与灰度正交设计
-
- 摘要
- @[toc]
- 1. 问题的真正根因:不是「颜色错了」,而是「分组信息载体错了」
-
- 1.1 因果链分析
- 1.2 核心命题
- 1.3 因果图
- 2. 数据结构:三个匹配维度 + 三个正交属性
-
- 2.1 条目 PlotStyleEntry
- 2.2 向后兼容的三个「不动点」
- 2.3 匹配维度:图层 > 颜色 > 线型
- 2.4 灰度化不改线宽
- 3. GB 预设与简化 CTB
-
- 3.1 gbPreset(LineWidthGroup):按线型分粗/细档
- 3.2 简化 CTB 文本格式
- 4. 七个真实踩坑(都是自测抓出来的,不是设想出来的)
-
- 4.1 QLocale::c().toDouble("0.70") 对合法字符串回报失败
- 4.2 `#` 既是注释符又是颜色前缀
- 4.3 `continue` 只跳出内层循环,半成品条目被写入
- 4.4 附带:QPixmap::setPixel 不可用
- 4.5 样张工具:QImage 设备上的双重 painter + DPI 失配
- 4.6 对照组基线选错会让回归测试永远「通过」
- 4.7 QPageSize::size() 不含纸张方向,样张右侧被裁掉 87mm
- 5. 打印链接入:抖动画刷与设备无关的网点尺寸
-
- 5.1 调用点
- 5.2 抖动仅作用于填充面
- 5.3 网点尺寸必须锚在设备空间
- 6. UI 与 COM 透传
-
- 6.1 页面设置
- 6.2 COM 批量出图
- 7. 验收对照
-
- 7.1 编译与自测
- 7.2 A4/A3 黑白样张(目检)
- 7.3 双路打印回归比对
- 8. 创新点与经验沉淀
-
- 8.1 创新点
- 8.2 经验沉淀
- 9. 未来工作
- 9. 未来工作
- 10. 结论
- 附录 A:符号表
- 附录 B:核心公式速查
- 附录 C:交付物清单
- 摘要
- @[toc]
- 1. 问题的真正根因:不是「颜色错了」,而是「分组信息载体错了」
-
- 1.1 因果链分析
- 1.2 核心命题
- 1.3 因果图
- 2. 数据结构:三个匹配维度 + 三个正交属性
-
- 2.1 条目 PlotStyleEntry
- 2.2 向后兼容的三个「不动点」
- 2.3 匹配维度:图层 > 颜色 > 线型
- 2.4 灰度化不改线宽
- 3. GB 预设与简化 CTB
-
- 3.1 gbPreset(LineWidthGroup):按线型分粗/细档
- 3.2 简化 CTB 文本格式
- 4. 七个真实踩坑(都是自测抓出来的,不是设想出来的)
-
- 4.1 QLocale::c().toDouble("0.70") 对合法字符串回报失败
- 4.2 `#` 既是注释符又是颜色前缀
- 4.3 `continue` 只跳出内层循环,半成品条目被写入
- 4.4 附带:QPixmap::setPixel 不可用
- 4.5 样张工具:QImage 设备上的双重 painter + DPI 失配
- 4.6 对照组基线选错会让回归测试永远「通过」
- 4.7 QPageSize::size() 不含纸张方向,样张右侧被裁掉 87mm
- 5. 打印链接入:抖动画刷与设备无关的网点尺寸
-
- 5.1 调用点
- 5.2 抖动仅作用于填充面
- 5.3 网点尺寸必须锚在设备空间
- 6. UI 与 COM 透传
-
- 6.1 页面设置
- 6.2 COM 批量出图
- 7. 验收对照
-
- 7.1 编译与自测
- 7.2 A4/A3 黑白样张(目检)
- 7.3 双路打印回归比对
- 8. 创新点与经验沉淀
-
- 8.1 创新点
- 8.2 经验沉淀
- 9. 未来工作
- 9. 未来工作
- 10. 结论
- 附录 A:符号表
- 附录 B:核心公式速查
- 附录 C:交付物清单
1. 问题的真正根因:不是「颜色错了」,而是「分组信息载体错了」
1.1 因果链分析
“彩色线宽图打印必错”这句话容易把人引向“把颜色映射成黑色”这个方向,但那只解决了一半。真正的因果链是:
1.2 核心命题
本设计的核心命题可形式化为:
灰度化
⊥
线宽分组
\\boxed{\\ \\text{灰度化} \\perp \\text{线宽分组}\\ }
灰度化⊥线宽分组
其中
⊥
\\perp
⊥ 表示正交。即:
灰度化只负责“去掉颜色”,线宽分组必须由样式映射表显式指定。 二者是正交的两件事,绝不能用“转灰度”顺带“猜”线宽。
1.3 因果图
形式化地,设:
-
C
C
C:颜色维度; -
W
W
W:线宽维度; -
B
W
\\mathrm{BW}
BW:黑白输出条件; -
O
K
\\mathrm{OK}
OK:出图正确。
在彩色输出下:
C
≠
∅
∧
W
≠
∅
⇒
O
K
C \\neq \\emptyset \\land W \\neq \\emptyset \\Rightarrow \\mathrm{OK}
C=∅∧W=∅⇒OK
在黑白输出下:
B
W
⇒
C
=
∅
\\mathrm{BW} \\Rightarrow C = \\emptyset
BW⇒C=∅
若
W
W
W 未显式给定,则:
C
=
∅
∧
W
=
W
derived
⇒
¬
O
K
C = \\emptyset \\land W = W_{\\text{derived}} \\Rightarrow \\neg \\mathrm{OK}
C=∅∧W=Wderived⇒¬OK
因此修复路径必须同时满足:
C
=
∅
∧
W
=
W
explicit
⇒
O
K
C = \\emptyset \\land W = W_{\\text{explicit}} \\Rightarrow \\mathrm{OK}
C=∅∧W=Wexplicit⇒OK
这条命题直接决定了后文所有数据结构与不变式。
2. 数据结构:三个匹配维度 + 三个正交属性
2.1 条目 PlotStyleEntry
在原有 overrideColor / overrideWidth 之上新增三个字段:
| overrideGray | 是否强制灰度化 | overrideGray | false |
| gray | 目标灰度 $0 = $ 黑,$1 = $ 白 | gray | 0.0 |
| dither | 灰度化后是否网点抖动 | dither | false |
2.2 向后兼容的三个「不动点」
tests 中逐条断言:
→
\\to
→ fromJson 用 value(…).toBool(false) / toDouble(0.0) 兜底;
→
\\to
→ resolve() 不做灰度化
→
\\to
→ 输出颜色与旧版逐字相同;
→
\\to
→ effectivePlotStyle() 返回空表
→
\\to
→ 所有 resolve() 走“沿用图元自身属性”分支
→
\\to
→ 仍是既有 G3 线宽组默认行为。
也就是说,本次改动对“不用样式表的用户”是零行为变更,这是硬约束“未选择样式表时保持现有 G3 线宽组默认行为不变”的技术兑现方式。
形式化地,设旧版输出为
O
0
O_0
O0,新版输出为
O
1
O_1
O1:
无样式表
∧
¬
m
o
n
o
c
h
r
o
m
e
⇒
O
1
=
O
0
\\text{无样式表} \\land \\neg \\mathrm{monochrome} \\Rightarrow O_1 = O_0
无样式表∧¬monochrome⇒O1=O0
2.3 匹配维度:图层 > 颜色 > 线型
原有实现只有两个维度(图层、颜色)。GB 黑白预设需要按线型分组(粗实 / 细实 / 虚线 / 点画线),因为国标的粗细分类本身就是按线型定义的。故新增第三张表 byStyle_:
QMap<QString, PlotStyleEntry> byLayer_; // 图层名 → 条目
QMap<int, PlotStyleEntry> byColor_; // QColor::rgba() 整数键 → 条目
QMap<int, PlotStyleEntry> byStyle_; // (int)LineStyle → 条目
resolve() 采用首个命中即返回(不再向下匹配):
const PlotStyleEntry* hit = nullptr;
auto lit = byLayer_.find(el.layer);
if (lit != byLayer_.end()) hit = &lit.value();
else { /* 颜色 */ } else { /* 线型 */ }
优先级可形式化定义为:
resolve
(
e
)
=
{
E
layer
(
e
.
l
a
y
e
r
)
,
若存在
,
E
color
(
e
.
c
o
l
o
r
)
,
若存在且无图层条目
,
E
style
(
e
.
s
t
y
l
e
)
,
若存在且无颜色条目
,
default
,
否则
.
\\operatorname{resolve}(e) = \\begin{cases} E_{\\text{layer}}(e.\\mathrm{layer}), & \\text{若存在}, \\\\ E_{\\text{color}}(e.\\mathrm{color}), & \\text{若存在且无图层条目}, \\\\ E_{\\text{style}}(e.\\mathrm{style}), & \\text{若存在且无颜色条目}, \\\\ \\text{default}, & \\text{否则}. \\end{cases}
resolve(e)=⎩
⎨
⎧Elayer(e.layer),Ecolor(e.color),Estyle(e.style),default,若存在,若存在且无图层条目,若存在且无颜色条目,否则.
为什么是「首个命中即返回」而不是「逐维度叠加合并」? 叠加语义会让“图层给宽度、颜色给灰度”这类组合产生难以预期的结果(用户无法预测最终线宽),而优先级短路的语义可预测性更强:用户一旦为某图层设了条目,就完全掌控它,不受图元颜色干扰。这与 AutoCAD CTB“先按对象再按颜色”的短路思路一致。
2.4 灰度化不改线宽
if (r.gray) r.color = plotGrayscale(r.color, r.grayValue);
// 注意:此行之后 widthMm 原封不动
plotGrayscale 是纯函数:
const int v = int(std::lround(g * 255.0)); // g=0 → 0(黑),g=1 → 255(白)
return QColor(v, v, v, c.alpha()); // 保留 alpha
灰度化公式为:
v
=
c
l
a
m
p
(
r
o
u
n
d
(
g
×
255
)
,
0
,
255
)
v = \\mathrm{clamp}\\left(\\mathrm{round}(g \\times 255),\\ 0,\\ 255\\right)
v=clamp(round(g×255), 0, 255)
其中
g
∈
[
0
,
1
]
g \\in [0, 1]
g∈[0,1] 为目标灰度,
v
v
v 为输出 RGB 通道值。输出颜色为:
R
G
B
out
=
(
v
,
v
,
v
)
\\mathrm{RGB}_{\\text{out}} = (v, v, v)
RGBout=(v,v,v)
Alpha 通道保留原值:
α
out
=
α
in
\\alpha_{\\text{out}} = \\alpha_{\\text{in}}
αout=αin
自测中专门断言“灰度化后 widthMm 不变”,把这条不变式固化,防止后来者“顺手”在灰度分支里也去改线宽——那正是“彩色线宽图打印必错”的另一种复现。
3. GB 预设与简化 CTB
3.1 gbPreset(LineWidthGroup):按线型分粗/细档
const double thick = groupThickMm(group); // G3 → 1.0
const double thin = groupThinMm(group); // G3 → 0.5
t.setStyleStyle(LineStyle::Solid, mk(thick)); // 粗实线
t.setStyleStyle(LineStyle::ThickDashed, mk(thick)); // 粗虚线
t.setStyleStyle(LineStyle::ThickDashDot, mk(thick)); // 粗点画线
t.setStyleStyle(LineStyle::Dashed, mk(thin)); // 细虚线
... 其余细线型同样 mk(thin)
其中 mk(w) 统一设 overrideGray = true, gray = 0, dither = false——线宽图要锐利,不抖动。粗/细档直接取自 LineWidthGroup.h(GB/T 14665-2012 表 1,粗:细恒
2
:
1
2:1
2:1),预设本身不硬编码任何线宽数值,因此切换图幅线组(G1…G5)后预设自动跟随。
粗细比公式:
r
thick:thin
=
w
thick
w
thin
=
2
r_{\\text{thick:thin}} = \\frac{w_{\\text{thick}}}{w_{\\text{thin}}} = 2
rthick:thin=wthinwthick=2
g
b
P
r
e
s
e
t
G
r
a
y
D
i
t
h
e
r
(
)
gbPresetGrayDither()
gbPresetGrayDither() 在此基础上开全局灰度+抖动,用于剖面/填充等需要中间调层次的场景。
3.2 简化 CTB 文本格式
真实 AutoCAD CTB 是二进制,本项目取其“行式文本子集”,便于版本管理/审阅/交付:
# 注释
LAYER 轮廓 WIDTH=0.70 GRAY=0.0
COLOR #FF0000 WIDTH=0.35 DITHER=1
STYLE DASHED WIDTH=0.50
GRAYSCALE 0.0 DITHER=1
fromCtb() 采用宽容解析:
- 未知关键字/非法数值
→
\\to
→ 记录错误、跳过该行,已解析的行照常生效; - 行内任一 token 出错
→
\\to
→ 整行丢弃(不写半成品条目); - 返回 false 并给出第一条错误串,供 UI 弹窗提示。
toCtb() 为其逆运算,便于把现场样式表导出给第三方或纳入存档审阅。
CTB 文法的形式化定义为:
Line
→
Directive
Token
∗
\\text{Line} \\to \\text{Directive}\\ \\text{Token}^*
Line→Directive Token∗
Directive
∈
{
LAYER
,
COLOR
,
STYLE
,
GRAYSCALE
}
\\text{Directive} \\in \\{\\text{LAYER}, \\text{COLOR}, \\text{STYLE}, \\text{GRAYSCALE}\\}
Directive∈{LAYER,COLOR,STYLE,GRAYSCALE}
Token
→
Key
=
Value
\\text{Token} \\to \\text{Key} = \\text{Value}
Token→Key=Value
Key
∈
{
WIDTH
,
GRAY
,
DITHER
}
\\text{Key} \\in \\{\\text{WIDTH}, \\text{GRAY}, \\text{DITHER}\\}
Key∈{WIDTH,GRAY,DITHER}
4. 七个真实踩坑(都是自测抓出来的,不是设想出来的)
这一节记录本次实现中由自测断言暴露、并最终修复的七个缺陷。它们都属于“看起来能跑、实际全错”的类别,值得单独留档。
4.1 QLocale::c().toDouble(“0.70”) 对合法字符串回报失败
现象:CTB 导入全部失败,提示 WIDTH 非法: 0.70,而打印出的字符码是 48, 46, 55, 48(即 "0.70",完全合法)。
排查过程:写独立探针程序编译链接同样的 Qt 6.10.1 / MinGW 13.1,发现三种写法在探针里全部成功:QLocale::c().toDouble / QString::toDouble / QString::fromLatin1().toDouble。再回到工程内联打印,发现 QLocale::c().toDouble 返回了正确的数值却把 ok 置为 false,而同一位置用 QString::toDouble 解析同一字符串则 ok = true。
结论:在本工具链组合下 QLocale::c()(C 区域、language 为空)这一路径的 ok 回报不可信。修复:自写 ctbToDouble() 作为全工程唯一数值入口,先把 , 归一为 .(抵御 de_DE 类逗号小数区域),再走 QString::toDouble:
static bool ctbToDouble(const QString& s, double* out) {
QString t = s.trimmed();
t.replace(QLatin1Char(','), QLatin1Char('.'));
bool ok = false;
const double v = t.toDouble(&ok);
if (ok && out) *out = v;
return ok;
}
教训:“解析成功与否”和“解析出的值”必须分别验证。只看 ok 会被区域设置坑,只看值又会被静默失败坑。
4.2 # 既是注释符又是颜色前缀
最初的行尾注释处理是 ln.left(ln.indexOf('#')),这会把 COLOR #FF0000 WIDTH=0.35 从中间截断成 COLOR,导致颜色映射整条失效。
修复规则:扫描每个 #,统计其后连续十六进制字符数,长度为 3 / 6 / 8 视为颜色 token(#RGB / #RRGGBB / #AARRGGBB),否则视为注释起点。
形式化判据:
isColorToken
(
#
s
)
⟺
∣
s
∣
∈
{
3
,
6
,
8
}
∧
s
∈
H
e
x
∗
\\text{isColorToken}(\\#s) \\iff |s| \\in \\{3, 6, 8\\} \\land s \\in \\mathrm{Hex}^*
isColorToken(#s)⟺∣s∣∈{3,6,8}∧s∈Hex∗
4.3 continue 只跳出内层循环,半成品条目被写入
for (int i = 2; …) 的 token 循环里遇到非法参数执行 continue,看似跳过了该 token,但外层的 setLayerStyle(target, e) 仍会执行,于是一个 WIDTH=abc 的条目被以默认值(0.25mm / 不灰度)写入表中——比直接报错更危险,因为用户看不出样式表已被污染。
修复:引入 lineBad 标志,循环结束后 if (lineBad) continue; 整行丢弃。
4.4 附带:QPixmap::setPixel 不可用
makeDitherBrush 最初用 QPixmap::setPixelColor / setPixel 写网点贴图,本工程编译单元报 no member named 'setPixel'。改用 QImage(Format_RGB32)逐像素写入后再 QPixmap::fromImage(),同时天然规避了“设备无关位图不能直接读写像素”的 Qt 约束。
4.5 样张工具:QImage 设备上的双重 painter + DPI 失配
生成双路回归图时首版输出全白(两路 PNG 字节数完全相同、彩色像素 0)。两个叠加原因:
DPI 换算公式为:
p
x
P
e
r
M
m
=
D
P
I
25.4
\\mathrm{pxPerMm} = \\frac{\\mathrm{DPI}}{25.4}
pxPerMm=25.4DPI
当 DPI 从
96
96
96 变为
300
300
300 时:
p
x
P
e
r
M
m
(
300
)
p
x
P
e
r
M
m
(
96
)
=
300
96
=
3.125
\\frac{\\mathrm{pxPerMm}(300)}{\\mathrm{pxPerMm}(96)} = \\frac{300}{96} = 3.125
pxPerMm(96)pxPerMm(300)=96300=3.125
即画布尺寸按
300
D
P
I
300\\,\\mathrm{DPI}
300DPI 计算、可打印区按
96
D
P
I
96\\,\\mathrm{DPI}
96DPI 计算,两者相差
3.125
3.125
3.125 倍。
这两条都属于“离线出图工具”与“真实打印设备(QPdfWriter 走 300dpi)”之间的环境差异,提醒我们:用 QImage 充当打印设备做回归时,必须显式对齐 DPI 与 painter 生命周期。
4.6 对照组基线选错会让回归测试永远「通过」
首版对照组设了 monochrome = true 想表达“仅灰度”,但 effectivePlotStyle() 的语义是“monochrome 且样式表为空
→
\\to
→ 自动套 GB 预设”——于是对照组也拿到了同一份 GB 预设,两路输出逐像素相同,
L
1
=
0
\\mathrm{L1} = 0
L1=0,回归测试形式上“无差异”。
修复:对照组必须是完全不带样式表、不带 monochrome 的原始彩色出图,即真正复现“彩色线宽图直接打到黑白打印机”这个错误基线。
教训:回归测试的对照组必须精确对应“修复前的真实行为”。用“另一个也开了修复开关的配置”当对照组,会得到“无差异”的假通过。断言 PASS 之前,先确认它在修复前会 FAIL。
4.7 QPageSize::size() 不含纸张方向,样张右侧被裁掉 87mm
目检 gray_A4_style.png 时发现第 4 列线段与外框右边完全不可见,只剩 3 列。初步判断是样张几何参数画得太宽(模型 225 mm vs A4 可打印 190 mm),于是把线段间距从
k
⋅
60
+
45
k \\cdot 60 + 45
k⋅60+45 收到
k
⋅
50
+
40
k \\cdot 50 + 40
k⋅50+40、外框从
(
−
15
,
18
)
∼
(
210
,
−
100
)
(-15, 18) \\sim (210, -100)
(−15,18)∼(210,−100) 收到
(
−
12
,
16
)
∼
(
205
,
−
100
)
(-12, 16) \\sim (205, -100)
(−12,16)∼(205,−100)——重编译后裁切依旧,说明根因不在几何。
真正原因在样张工具的画布构造:
const QSizeF szMm = size.size(QPageSize::Millimeter); // A4 → 210×297(纵向!)
const int W = int(szMm.width() * pxPerMm); // 建出纵向画布
const int H = int(szMm.height() * pxPerMm);
QPageSize::size() 返回的是纸张固有尺寸(永远是纵向基态),与 orientation 无关。而 PrintEngine::render() 内部走 effectivePageW/H(),横向下按
297
×
210
297 \\times 210
297×210 排版:
return (s.orientation == QPageLayout::Landscape) ? ps.height() : ps.width();
于是画布 210 mm 宽、排版 297 mm 宽,右侧
87
m
m
87\\,\\mathrm{mm}
87mm 直接落在画布外被裁。PNG 尺寸
780
×
1054
780 \\times 1054
780×1054(纵向)本身就是最直接的证据——而设置里明明写的是 Landscape。
修复:画布尺寸按方向交换宽高,与 effectivePageW/H() 保持同一套换算。
const double pageWmm = (s.orientation == QPageLayout::Landscape) ? szMm.height() : szMm.width();
const double pageHmm = (s.orientation == QPageLayout::Landscape) ? szMm.width() : szMm.height();
修复后 PNG 变为
3507
×
2480
3507 \\times 2480
3507×2480(横向),4 列线段与外框四边完整闭合。
教训:QPageSize::size() 是纸张尺寸,QPageLayout 才是版面尺寸,两者差一个方向换算。凡是“离屏设备尺寸”与“打印设置”两处独立计算的地方,都必须复用引擎内部的换算函数,否则必然出现静默裁切。这类 bug 不报错、只丢内容——第一反应怀疑几何参数是本能误导,必须先看输出图的宽高比是否与设置的方向一致。注意 PDF 路径不受影响:QPdfWriter::setPageOrientation() 自己处理了方向,所以只有离屏 QImage 回归图会错,这也解释了为何 PDF 样张一直是好的。
5. 打印链接入:抖动画刷与设备无关的网点尺寸
5.1 调用点
// PrintEngine.cpp
PlotRenderer::renderModel(&p, model, scaleDev, clipWorld,
s.effectivePlotStyle(), s.lineWidthScale);
灰度信息经 resolve() 已经在 rs.color 中了,所有 paint* 分支(线 / 剖面 / 标注 / 文字 / 块引用 / 表格 / 边界)因为统一使用 rs.color 而自动继承灰度化,无需逐处改动。这是把“颜色决策”收敛到 resolve() 一处的收益。
调用链可形式化为:
Model
→
resolve
{
(
e
,
r
s
)
}
→
render
Device
\\text{Model} \\xrightarrow{\\operatorname{resolve}} \\{(e, \\mathrm{rs})\\} \\xrightarrow{\\operatorname{render}} \\text{Device}
Modelresolve
{(e,rs)}render
Device
所有绘制分支共享同一 rs.color,因此颜色决策单点生效。
5.2 抖动仅作用于填充面
if (rs.gray && rs.dither) {
p->setBrush(makeDitherBrush(rs.grayValue, c.scaleDev)); // 网点
} else {
p->setBrush(rs.color); // 实灰度
}
只接在 paintHatch 的 SolidFill(狭小区域涂黑)分支:线宽图的轮廓线若抖动会变毛、失去锐度,而面状填充抖动才有意义(恢复中间灰层次)。这是按“笔画 vs 填充”区分抖动的必要性。
5.3 网点尺寸必须锚在设备空间
QBrush 的贴图 tile 默认随世界变换缩放。图幅放大时网点会跟着变粗、退化成灰块。修复:
br.setTransform(QTransform::fromScale(1.0/scaleDev, 1.0/scaleDev));
抵消世界变换,使网点在纸面上恒为固定物理尺寸(默认 4 设备像素)。
网点密度定义为黑点数占比:
ρ
black
=
1
−
g
\\rho_{\\text{black}} = 1 – g
ρblack=1−g
其中
g
g
g 为目标灰度。当
g
=
0
g = 0
g=0 时
ρ
black
=
1
\\rho_{\\text{black}} = 1
ρblack=1(全黑),当
g
=
1
g = 1
g=1 时
ρ
black
=
0
\\rho_{\\text{black}} = 0
ρblack=0(全白),方向与“
g
g
g 越大越浅”一致。
网点 tile 尺寸为:
t
tile
=
4
设备像素
t_{\\text{tile}} = 4\\ \\text{设备像素}
ttile=4 设备像素
经设备空间锚定后,纸面上的物理尺寸为:
t
paper
=
t
tile
p
x
P
e
r
M
m
=
4
p
x
P
e
r
M
m
m
m
t_{\\text{paper}} = \\frac{t_{\\text{tile}}}{\\mathrm{pxPerMm}} = \\frac{4}{\\mathrm{pxPerMm}}\\ \\mathrm{mm}
tpaper=pxPerMmttile=pxPerMm4 mm
6. UI 与 COM 透传
6.1 页面设置
PrintSettingsDialog 的“打印样式(简化 CTB)”组新增:
- 样式表预设下拉:无 / GB 黑白出图 / GB 灰度抖动 / 自定义;
- 导入 CTB… 按钮:读文件
→
\\to
→ fromCtb→
\\to
→ 解析有瑕疵时弹“已跳过哪些行”; - 黑白出图(打印机 / PDF) 总开关:写入 PrintSettings::monochrome;
- 匹配维度加**“按线型”**(弹中文列表,内部转 plotStyleName);
- 条目编辑加灰度 / 抖动两个控件;
- 列表项描述附灰度后缀(如 灰度0.30(抖动)),目检前无需点开每条就能确认黑白意图。
monochrome 与预设的分工:effectivePlotStyle() 里只有当表为空时才套 GB 预设,用户手工建过表就以手工表为准——避免“勾了黑白开关就把精心设的线宽分组抹掉”。
形式化地:
effectivePlotStyle
(
s
e
t
t
i
n
g
s
)
=
{
G
B
P
r
e
s
e
t
,
若
m
o
n
o
c
h
r
o
m
e
∧
s
t
y
l
e
T
a
b
l
e
=
∅
,
s
t
y
l
e
T
a
b
l
e
,
若
s
t
y
l
e
T
a
b
l
e
≠
∅
,
∅
,
否则
.
\\operatorname{effectivePlotStyle}(\\mathrm{settings}) = \\begin{cases} \\mathrm{GBPreset}, & \\text{若 } \\mathrm{monochrome} \\land \\mathrm{styleTable} = \\emptyset, \\\\ \\mathrm{styleTable}, & \\text{若 } \\mathrm{styleTable} \\neq \\emptyset, \\\\ \\emptyset, & \\text{否则}. \\end{cases}
effectivePlotStyle(settings)=⎩
⎨
⎧GBPreset,styleTable,∅,若 monochrome∧styleTable=∅,若 styleTable=∅,否则.
6.2 COM 批量出图
BatchTask 新增两个字段,exportDrawing 加同名默认参(ABI 友好):
QString ctbText; // 简化 CTB 文本
QString preset; // "gb" | "gbGray" | "none"
任务 JSON 里支持 "styleTable" 字段,自动判别是预设名还是 CTB 文本(含换行或 LAYER / COLOR / STYLE / GRAYSCALE 关键字
→
\\to
→ CTB 文本)。ctbText 优先于 preset。
判别规则可形式化:
isCtb
(
s
)
⟺
(
s
∋
\\n
)
∨
(
s
∋
{
LAYER
,
COLOR
,
STYLE
,
GRAYSCALE
}
)
\\operatorname{isCtb}(s) \\iff \\left( s \\ni \\texttt{\\textbackslash n} \\right) \\lor \\left( s \\ni \\{\\texttt{LAYER}, \\texttt{COLOR}, \\texttt{STYLE}, \\texttt{GRAYSCALE}\\} \\right)
isCtb(s)⟺(s∋\\n)∨(s∋{LAYER,COLOR,STYLE,GRAYSCALE})
外部系统无需手工拼整份 PrintSettings JSON 即可为每张图指定黑白线宽 / 灰度。
7. 验收对照
7.1 编译与自测
| SketchCAD 构建 | EXIT=0,-Wall -Wextra 零警告 |
| SketchSelfTest 构建 | EXIT=0,零警告 |
| SketchBenchmark 构建 | EXIT=0,零警告 |
| PlotStyleSamples 构建 | EXIT=0,零警告 |
| 自测用例 | 通过 344 / 失败 0(315 → 344,新增 t_plotStyle()) |
315
→
344
315 \\to 344
315→344 的 29 条新断言覆盖:映射解析(颜色
→
\\to
→ 线宽)、优先级(图层 > 颜色 > 线型)、灰度纯函数(
0
/
0.5
/
1
0 / 0.5 / 1
0/0.5/1 三点)、灰度不改线宽、GB 预设粗细分组(G3:
1.0
/
0.5
1.0 / 0.5
1.0/0.5)、CTB 四关键字解析、CTB 宽容容错、JSON 往返、旧档缺键兼容。
7.2 A4/A3 黑白样张(目检)
样张由 PlotStyleSamples 生成,四类线型各一行、每行四条同色线,刻意用四个明显不同的彩色以复现“彩色线宽图”场景:
| bw_A4_style.pdf / bw_A3_style.pdf | 带 GB 样式表的黑白样张(待目检签字) |
| bw_A4_nostyle.pdf / bw_A3_nostyle.pdf | 对照组:完全不带样式表、不带 monochrome 的原始彩色出图 |
| gray_A4_style.png / gray_A4_nostyle.png | 双路回归用的灰度位图(同一条 PrintEngine::render 链路) |
| regression.txt | 双路灰度直方图 / 墨量 / 彩色像素比对报告 |
对照组刻意取“原始彩色 + 自动派生线宽”,即修复前彩色线宽图直接打印的真实行为;若对照组也开 monochrome,effectivePlotStyle() 会给它套同一份 GB 预设,两路将逐像素相同,回归测试就成了假通过(详见 4.6)。
目检要点(对照 GB/T 14665):
- 粗实线:最粗且纯黑;
- 细实线:约为粗线一半宽、纯黑;
- 虚线:细档、线段间断均匀;
- 点画线:细档、长短段交替。
签字确认由人工在打开 PDF 后完成(本记录为样张产出与自动化回归部分)。
7.3 双路打印回归比对
PlotStyleSamples 对“带样式表 / 不带样式表”两路各渲染一份灰度图(复用同一条 PrintEngine::render 链路,故比对的即是真实出图路径),统计:
- 平均灰度
g
ˉ
\\bar{g}
gˉ; - 总墨量
Σ
(
255
−
g
)
\\Sigma(255 – g)
Σ(255−g) 及相对变化; - 256 桶灰度直方图的
L
1
\\mathrm{L1}
L1 差异; - 彩色像素计数(
R
≠
G
≠
B
\\mathrm{R} \\neq \\mathrm{G} \\neq \\mathrm{B}
R=G=B 的抽样像素)——这是“黑白化是否彻底”的直接判据。
判定:两路差异可测(样式表确实改变了出图)且带样式表组彩色像素为 0(黑白化正确)。
关键指标形式化定义:
平均灰度:
g
ˉ
=
1
N
∑
i
=
1
N
g
i
\\bar{g} = \\frac{1}{N} \\sum_{i=1}^{N} g_i
gˉ=N1i=1∑Ngi
总墨量:
M
=
∑
i
=
1
N
(
255
−
g
i
)
M = \\sum_{i=1}^{N} (255 – g_i)
M=i=1∑N(255−gi)
灰度直方图
L
1
\\mathrm{L1}
L1 差异:
L
1
=
∑
b
=
0
255
∣
H
style
(
b
)
−
H
nostyle
(
b
)
∣
\\mathrm{L1} = \\sum_{b=0}^{255} \\left| H_{\\text{style}}(b) – H_{\\text{nostyle}}(b) \\right|
L1=b=0∑255∣Hstyle(b)−Hnostyle(b)∣
彩色像素计数:
N
color
=
∣
{
i
∣
R
i
≠
G
i
∨
G
i
≠
B
i
}
∣
N_{\\text{color}} = \\left| \\{ i \\mid R_i \\neq G_i \\lor G_i \\neq B_i \\} \\right|
Ncolor=∣{i∣Ri=Gi∨Gi=Bi}∣
实测结果(A4 / A3 双 PASS,EXIT=0):
| 平均灰度 | 251.16 | 251.78 | 253.06 | 253.36 |
| 总墨量
Σ ( 255 − g ) \\Sigma(255 – g) Σ(255−g) |
33 362 717 | 28 020 543 | 33 804 707 | 28 462 547 |
| 墨量变化 | +19.1% | — | +18.8% | — |
| 灰度直方图
L 1 \\mathrm{L1} L1 |
119 626(1.3754%) | 112 052(0.6442%) | ||
| 彩色像素(抽样) | 0 | 15 471 | 0 | 14 522 |
| 判定 | PASS | PASS |
三点关键读数:
0
0
0 vs
15471
15471
15471 —— 样式表组彻底灰度化,对照组保留原始彩色。黑白化正确。
+
19.1
%
+19.1\\%
+19.1% /
+
18.8
%
+18.8\\%
+18.8% —— 差异方向正确且量级合理。GB 预设把粗实线拉到
1.0
m
m
1.0\\,\\mathrm{mm}
1.0mm、细线类压到
0.5
m
m
0.5\\,\\mathrm{mm}
0.5mm(
2
:
1
2 : 1
2:1 分组),而对照组四类线全是同一自动派生宽度。带样式表组着墨更多 = 线宽分组真实生效,而非仅仅“变成了灰色”。
L
1
\\mathrm{L1}
L1 差异
1.38
%
1.38\\%
1.38% /
0.64
%
0.64\\%
0.64% —— 远超
10
−
4
10^{-4}
10−4 判定阈值,不是噪声。
目检结论(对 gray_A4_style.png / bw_A4_style.pdf):
- 四列线段完整可见、外框四边闭合(横向
3507
×
2480
3507 \\times 2480
3507×2480,见 4.7 纸张方向修复); - 粗实线最粗且纯黑,细实线明显较细(约一半宽),虚线为细档间断,点画线为细档长短段交替;
- 四类线型纯灰度下仍可凭线宽清晰区分 —— 即本任务要达成的核心目标。
对照 gray_A4_nostyle.png 可见:四类线粗细完全一致,仅靠红 / 蓝 / 绿 / 黄区分。这正是“彩色线宽图打印必错”的现场:一旦落到黑白输出机或黑白 PDF,颜色维度归零,四类线将无法辨识。
PDF 样张的人工签字确认仍待人工在阅读器中打开 bw_A4_style.pdf / bw_A3_style.pdf 后完成。
8. 创新点与经验沉淀
8.1 创新点
C
=
∅
∧
W
=
W
explicit
⇒
O
K
C = \\emptyset \\land W = W_{\\text{explicit}} \\Rightarrow \\mathrm{OK}
C=∅∧W=Wexplicit⇒OK 形式化表达黑白出图正确性条件,明确灰度与线宽是两个独立维度。这一形式化不仅澄清了“转灰度”与“线宽分组”的职责边界,还直接推导出后文全部数据结构与不变式,使设计从“经验直觉”上升为“可验证命题”。
2
:
1
2 : 1
2:1,与 GB/T 14665-2012 对齐。预设本身不硬编码任何线宽数值,而是从 LineWidthGroup 动态取档,因此切换图幅线组(G1…G5)后预设自动跟随,避免了“换图幅就要改样式表”的维护负担。
L
1
\\mathrm{L1}
L1 差异,共同判定黑白化彻底性与线宽分组有效性。三指标互为补充:彩色像素计数直接回答“黑白化是否彻底”,墨量变化回答“线宽分组是否真实生效”,直方图
L
1
\\mathrm{L1}
L1 回答“差异是否显著”,任一单指标都容易被“假通过”欺骗。
8.2 经验沉淀
- 把“颜色决策”收敛到 resolve() 一处,让所有渲染分支自动继承,是本次改动能只动一行调用点就覆盖全部图元类型的原因;分散决策必然漏改某一类图元。这一“单点决策”原则适用于任何跨多分支的横切属性(颜色、线宽、透明度等),是降低渲染管线耦合度的通用手法。
- “灰度”与“线宽”正交是本任务最容易做错的地方。一旦让灰度分支顺手改线宽(或反过来让线宽映射隐含改灰度),就会产出“看起来是黑白图、但分组是乱的”这种更难排查的问题。正交性不仅是设计命题,更要在自测中用“灰度化后 widthMm 不变”这类不变式钉死,防止后来者“顺手”破坏。
- 宽容解析必须有“整行丢弃”兜底:只跳过坏 token 会写出半成品条目,比严格报错更危险。半成品条目以默认值(0.25 mm / 不灰度)写入表中,用户看不出样式表已被污染,问题会潜伏到出图阶段才暴露。宽容解析的正确姿势是“坏行丢弃 + 已解析行生效 + 返回错误串供 UI 提示”。
- ok 标志不可单独信任:本工程实测 QLocale::c().toDouble 返回真值却置 ok = false。涉及用户输入的数值解析,建议自写归一化包装并在自测中用“预期值”而非仅“ok”断言。“解析成功与否”和“解析出的值”必须分别验证,只看 ok 会被区域设置坑,只看值又会被静默失败坑。
- 逗号 vs 句点小数点是 CTB / 配置文件解析的国际性坑点,归一化比依赖 QLocale 更稳。de_DE 类区域用逗号作小数分隔符,直接 toDouble 会静默失败或解析出错误值;先归一化再解析是跨区域配置解析的通用防御。
- 验收样张要刻意复现失败场景(用四个高对比彩色画线),而不是用“本来就黑白”的图去验证黑白出图——后者会让回归测试永远通过。验证必须针对“修复前的真实失败行为”设计,否则测试通过毫无意义。
- QPageSize::size() 是纸张尺寸,不含方向。离屏 QImage 当打印设备时,画布尺寸必须复用引擎的 effectivePageW/H() 换算,否则画布纵向、排版横向,静默裁掉
87
m
m
87\\,\\mathrm{mm}
87mm。这类 bug 不报错只丢内容,且第一反应怀疑几何参数是本能误导——应先看输出图宽高比是否与设置方向一致。(QPdfWriter 路径自带方向处理,故只有离屏回归图会错。) - 回归测试的对照组必须精确对应“修复前的真实行为”。用“另一个也开了修复开关的配置”当对照组,会得到“无差异”的假通过。断言 PASS 之前,先确认它在修复前会 FAIL——这是回归测试设计的一条通用铁律。
- “离屏出图工具”与“真实打印设备”之间存在环境差异(DPI、painter 生命周期、纸张方向),用 QImage 充当打印设备做回归时,必须显式对齐 DPI 与 painter 生命周期,否则会得到“全白”或“裁切”这类静默错误。
9. 未来工作
0.34
m
m
0.34\\,\\mathrm{mm}
0.34mm,但在 600dpi 下会缩到
0.17
m
m
0.17\\,\\mathrm{mm}
0.17mm,可能低于打印机的物理分辨率极限;自适应策略可按 pxPerMm 动态调整 tile 尺寸。
→
\\to
→ 灰度的感知一致映射。当前灰度化是简单的亮度线性映射,不同色相在感知上可能映射到相近灰度;引入 CIELAB
Δ
E
\\Delta E
ΔE 度量后,可让不同色相映射到感知均匀的灰度梯度,提升可辨识度。
L
1
\\mathrm{L1}
L1 数值,叠加直方图可让人工快速定位差异集中在哪个灰度区间,辅助判断差异是否合理(如集中在粗线档位)。
9. 未来工作
→
\\to
→ 灰度的感知一致映射;
10. 结论
本文给出了一套面向黑白出图的打印样式映射设计方案。该方案以“灰度与线宽正交”为核心命题,以三维匹配 + 三正交属性为数据结构、优先级短路为匹配语义、GB 预设为出厂配置、简化 CTB 为交换格式、抖动画刷为填充增强、双路回归为验证手段,系统解决了“彩色线宽图打印必错”问题。验证表明,A4 / A3 双路回归在三项指标(彩色像素、墨量、直方图
L
1
\\mathrm{L1}
L1)上均判定 PASS,样式表组彩色像素为 0,墨量提升
+
19.1
%
/
+
18.8
%
+19.1\\% / +18.8\\%
+19.1%/+18.8%,线宽分组真实生效,黑白化彻底。
附录 A:符号表
|
C C C |
颜色维度 |
|
W W W |
线宽维度 |
|
B W \\mathrm{BW} BW |
黑白输出条件 |
|
g g g |
目标灰度,
g ∈ [ 0 , 1 ] g \\in [0, 1] g∈[0,1] |
|
v v v |
输出 RGB 通道值 |
|
α \\alpha α |
Alpha 通道 |
|
r thick:thin r_{\\text{thick:thin}} rthick:thin |
粗 : 细线宽比 |
|
D P I \\mathrm{DPI} DPI |
每英寸点数 |
|
p x P e r M m \\mathrm{pxPerMm} pxPerMm |
每毫米像素数 |
|
ρ black \\rho_{\\text{black}} ρblack |
黑点密度 |
|
t tile t_{\\text{tile}} ttile |
网点 tile 设备像素尺寸 |
|
t paper t_{\\text{paper}} tpaper |
网点 tile 纸面物理尺寸 |
|
g ˉ \\bar{g} gˉ |
平均灰度 |
|
M M M |
总墨量 |
|
L 1 \\mathrm{L1} L1 |
灰度直方图
L 1 \\mathrm{L1} L1 差异 |
|
N color N_{\\text{color}} Ncolor |
彩色像素计数 |
附录 B:核心公式速查
灰度化:
v
=
c
l
a
m
p
(
r
o
u
n
d
(
g
×
255
)
,
0
,
255
)
,
R
G
B
out
=
(
v
,
v
,
v
)
v = \\mathrm{clamp}\\left(\\mathrm{round}(g \\times 255),\\ 0,\\ 255\\right), \\quad \\mathrm{RGB}_{\\text{out}} = (v, v, v)
v=clamp(round(g×255), 0, 255),RGBout=(v,v,v)
粗细比:
r
thick:thin
=
w
thick
w
thin
=
2
r_{\\text{thick:thin}} = \\frac{w_{\\text{thick}}}{w_{\\text{thin}}} = 2
rthick:thin=wthinwthick=2
DPI 换算:
p
x
P
e
r
M
m
=
D
P
I
25.4
\\mathrm{pxPerMm} = \\frac{\\mathrm{DPI}}{25.4}
pxPerMm=25.4DPI
网点密度:
ρ
black
=
1
−
g
\\rho_{\\text{black}} = 1 – g
ρblack=1−g
网点纸面尺寸:
t
paper
=
t
tile
p
x
P
e
r
M
m
m
m
t_{\\text{paper}} = \\frac{t_{\\text{tile}}}{\\mathrm{pxPerMm}}\\ \\mathrm{mm}
tpaper=pxPerMmttile mm
平均灰度:
g
ˉ
=
1
N
∑
i
=
1
N
g
i
\\bar{g} = \\frac{1}{N} \\sum_{i=1}^{N} g_i
gˉ=N1i=1∑Ngi
总墨量:
M
=
∑
i
=
1
N
(
255
−
g
i
)
M = \\sum_{i=1}^{N} (255 – g_i)
M=i=1∑N(255−gi)
直方图
L
1
\\mathrm{L1}
L1 差异:
L
1
=
∑
b
=
0
255
∣
H
style
(
b
)
−
H
nostyle
(
b
)
∣
\\mathrm{L1} = \\sum_{b=0}^{255} \\left| H_{\\text{style}}(b) – H_{\\text{nostyle}}(b) \\right|
L1=b=0∑255∣Hstyle(b)−Hnostyle(b)∣
彩色像素判据:
N
color
=
∣
{
i
∣
R
i
≠
G
i
∨
G
i
≠
B
i
}
∣
=
0
N_{\\text{color}} = \\left| \\{ i \\mid R_i \\neq G_i \\lor G_i \\neq B_i \\} \\right| = 0
Ncolor=∣{i∣Ri=Gi∨Gi=Bi}∣=0
优先级短路:
resolve
(
e
)
=
{
E
layer
(
e
.
l
a
y
e
r
)
,
若存在
,
E
color
(
e
.
c
o
l
o
r
)
,
若存在且无图层条目
,
E
style
(
e
.
s
t
y
l
e
)
,
若存在且无颜色条目
,
default
,
否则
.
\\operatorname{resolve}(e) = \\begin{cases} E_{\\text{layer}}(e.\\mathrm{layer}), & \\text{若存在}, \\\\ E_{\\text{color}}(e.\\mathrm{color}), & \\text{若存在且无图层条目}, \\\\ E_{\\text{style}}(e.\\mathrm{style}), & \\text{若存在且无颜色条目}, \\\\ \\text{default}, & \\text{否则}. \\end{cases}
resolve(e)=⎩
⎨
⎧Elayer(e.layer),Ecolor(e.color),Estyle(e.style),default,若存在,若存在且无图层条目,若存在且无颜色条目,否则.
附录 C:交付物清单
| 核心样式 | src/core/PlotStyle.{h,cpp}(灰度 / 抖动 / GB 预设 / CTB) |
| 打印引擎 | src/ui/PrintEngine.{h,cpp}(monochrome + effectivePlotStyle) |
| 渲染器 | src/ui/PlotRenderer.cpp(makeDitherBrush + 灰度画刷) |
| UI 对话框 | src/ui/PrintSettingsDialog.{h,cpp}(预设 / 导入 / 灰度控件) |
| COM 批量 | src/com/BatchPlotEngine.{h,cpp}(ctbText / preset 透传) |
| 样张工具 | bench/PlotStyleSamples.cpp(A4 / A3 样张 + 双路回归) |
| 自测 | t_plotStyle()(29 条断言) |
| 文档 | 本文 |




