mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
M3 复核修正(第 2 轮):紧挨着核对过的数字的字按码点分类收紧,列表缩进有上限、表格只排有格的列
复核发现「紧挨着」只认一部分字:隔着 DEL、U+FFF0 这类显示时不占位置的字补一位数,或者前面加一个 U+2010 这类和负号 长得一样的横线,读的人看到的数已经变了,页面仍「全部通过」;伪造的列表缩进写成十万级,核对通过的数字被挤出屏幕仍判 通过。 - 紧挨着的字按码点分几类(kindOf):数字;负号(横线类标点全算、波浪形的除外,减号类符号,Unicode 易混字符表里映射到 「-」「ー」的字,在 Chrome 常用字体里逐个画出来是一道横线的字);分隔(半角逗号、句点和易混字符表里长得一样的窄标点, 全角的不算);不占位置的格式字符、默认不显示的字、变体选择符照旧跳过;控制字符(制表、换行等空白除外)、未分配和私用 区的字、孤立的代理项、组合附加符号、可选连字符、发丝空格、对象替换符和在 Chrome 里量出来几乎不占宽度的几个字算「说不 准」:隔着它们照样往外找数字、负号,找不到的也判无法核对、原因另写。只跳过不标记的话,叠在空格上的组合横线、Chrome 画成短横的 C1 控制字符照样能在数字前面显示出一个「负号」 - 同一句里有从右往左书写的文字(希伯来文、阿拉伯文这类)和方向控制字符一样判无法核对:Chrome 里实测,一个希伯来字母 就能把逻辑上在数字后面的「5」显示到数字紧前面(读作 574,613.65元),按字的先后看不出来 - 代理对只在真成对时拼成一个字:不成对的前半不再把后面的「1」吞进去;分在两个文字片段里的一对页面上照样显示成数学字母 区的数字,也拼起来看 - 列表缩进最多显示 8 级,负数按 0 级:十万级的缩进把数字挤到 140 万像素以外,负的连滚动都滚不到;表格只排有格的列:列号 写成十万时不再建几十万个空格、把那一格挤到右边,写成 2^32 时整段核对也不再出错。核对和显示共用 listItems / tableCells - 真数据(两份数据目录里 39 份报告文档、1591 处数字和取值、45 个列表、70 张表格)里新旧规则都一处没有触发 - 测试:test_bundle_shown.py 补 DEL、U+FFF0、U+2010、U+2013 等紧挨着的写法,说不准的字、从右往左的文字、缩进和列号的 用例,逐项去掉实现都会变红;check-bundle 在经营月报上补四种紧挨着的伪造(从右往左那条先量出「5」确实显示在数字紧前 面),缩进写成十万级和负数(宽屏、窄屏)、表格列号写成十万的伪造
This commit is contained in:
+134
-45
@@ -13,7 +13,8 @@
|
||||
* render_number / render_cell:千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)把那个值渲染出来的字,
|
||||
* 两边都只去掉首尾空白。本页渲染不出来的写法判无法核对,不判通过。运行输入不随文件附带,照旧无法核对,但显示的字
|
||||
* 照样拿引用的值核对,对不上判不通过。核对通过的字要原样显示在正文里(checkLayout):正文里根本不显示的判不通过,
|
||||
* 紧挨着还有数字或负号、读起来不是这个值的判无法核对;
|
||||
* 紧挨着还有数字、负号或显示成什么说不准的字,或者同一句里有改变文字方向的字,读起来可能不是这个值的判无法核对;
|
||||
* 列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数;
|
||||
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
|
||||
* 绑不进封存,页面上写明它们取自运行记录。
|
||||
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、报告文档标着遮罩)
|
||||
@@ -174,7 +175,8 @@
|
||||
shownBroken: '本页按系统的显示规则处理这个值时出错',
|
||||
/** 正文的排法(checkLayout):显示文字核对过了,读起来却不是这个值 */
|
||||
shownGlued: '紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值',
|
||||
shownTurned: '这一句里有改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样',
|
||||
shownBlurred: '紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值',
|
||||
shownTurned: '这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样',
|
||||
numNotShown: '正文里没有显示这一处:导出时不会出现这种情况,文件可能被改过',
|
||||
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
|
||||
statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`,
|
||||
@@ -206,7 +208,7 @@
|
||||
shaJs: 'SHA-256 由本页内置的实现计算(浏览器未提供 crypto.subtle),结果与浏览器内置实现一致',
|
||||
renderNote: '正文里带出处的数字和取值,本页按系统的显示规则(千分位、小数位、单位、万 / 亿换算、百分数,文字压成一行)'
|
||||
+ '把口径卡或快照里的值(运行输入按引用的值)重新渲染一遍,与显示的文字逐字比对;核对通过的字在正文里原样显示,'
|
||||
+ '紧挨着它还有数字或负号的判无法核对。',
|
||||
+ '紧挨着它还有数字、负号或看不见的字,或者同一句里有改变文字方向的字,判无法核对。',
|
||||
schema: (s) => `文件格式 ${s}`,
|
||||
}
|
||||
|
||||
@@ -731,31 +733,41 @@
|
||||
return { hide, style }
|
||||
}
|
||||
|
||||
/** 列表的各项:一项从行首写着列表符号或序号的结构片段开始,后面的句子接在这一项里 */
|
||||
// 列表最多显示这么多级缩进(每级 14px)。服务端按原文的缩进算级数(两个空格一级),没有上限:伪造的级数写成十万、
|
||||
// 负数,那一项会被挤出屏幕或挤到左边看不见的地方,读的人看不到核对通过的数,页面却照样通过;窄屏上也会横向溢出
|
||||
const MAX_DEPTH = 8
|
||||
|
||||
/** 列表的各项:一项从行首写着列表符号或序号的结构片段开始,后面的句子接在这一项里。depth 是显示的缩进级数 */
|
||||
function listItems(units) {
|
||||
const items = []
|
||||
for (const u of units) {
|
||||
const lead = Array.isArray(u.segments) ? u.segments[0] : null
|
||||
const starts = !items.length || (isObj(lead) && lead.kind === 'structural' && ITEM_START.test(String(lead.text ?? '')))
|
||||
if (starts) items.push({ depth: Number(u.depth) || 0, units: [u] })
|
||||
const depth = Math.min(Math.max(Math.floor(Number(u.depth)) || 0, 0), MAX_DEPTH)
|
||||
if (starts) items.push({ depth, units: [u] })
|
||||
else items[items.length - 1].units.push(u)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** 表格:{ cells(位置 → 句子,一格可能被分成几句,按位置归拢), rows(表身各行的行号,表头是 -1,另算), cols } */
|
||||
/**
|
||||
* 表格:{ cells(位置 → 句子,一格可能被分成几句,按位置归拢), rows(表身各行的行号,表头是 -1,另算), cols(有格的
|
||||
* 列号,从小到大)}。服务端每行从第 0 列起一格不落,列号连着;只排有格的列:伪造的列号写成十万,那一格紧接着前一列
|
||||
* 显示,不会被几万个空列挤到表格右边看不见的地方(也不会建出几十万个空格、拖垮页面)
|
||||
*/
|
||||
function tableCells(units) {
|
||||
const cells = new Map()
|
||||
const rows = []
|
||||
let cols = 0
|
||||
const cols = new Set()
|
||||
for (const u of units) {
|
||||
const loc = isObj(u.loc) ? u.loc : { row: 0, col: 0 }
|
||||
push(cells, `${loc.row}:${loc.col}`, u)
|
||||
cols = Math.max(cols, (Number(loc.col) || 0) + 1)
|
||||
const col = Number(loc.col)
|
||||
if (Number.isInteger(col) && col >= 0) cols.add(col)
|
||||
if (loc.row >= 0 && !rows.includes(loc.row)) rows.push(loc.row)
|
||||
}
|
||||
rows.sort((a, b) => a - b)
|
||||
return { cells, rows, cols }
|
||||
return { cells, rows, cols: [...cols].sort((a, b) => a - b) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -772,7 +784,7 @@
|
||||
return listItems(units).map((item) => item.units)
|
||||
case 'table': {
|
||||
const { cells, rows, cols } = tableCells(units)
|
||||
return [-1, ...rows].flatMap((r) => Array.from({ length: cols }, (_, c) => cells.get(`${r}:${c}`) || []))
|
||||
return [-1, ...rows].flatMap((r) => cols.map((c) => cells.get(`${r}:${c}`) || []))
|
||||
}
|
||||
default:
|
||||
return [units]
|
||||
@@ -807,27 +819,72 @@
|
||||
return plan
|
||||
}
|
||||
|
||||
// 判断紧挨着的字:数字(Python 的 \d 认的全都算,全角的、数学字母区的也是)、负号(半角、全角、小号、数学减号);
|
||||
// 看不见的字(零宽空格这类格式字符、组合附加符号)跳过,隔着它们照样算紧挨着。改变文字方向的控制字符另算:它能把
|
||||
// 后面的一位数挪到数字前面显示,按字的先后看不出来
|
||||
// 紧挨着核对通过的数字、取值的字,按码点分几类(kindOf):
|
||||
// - digit:数字。Python 的 \d 认的全都算(全角的、数学字母区的、阿拉伯文的也是);
|
||||
// - minus:负号。横线类标点(连字符、各种破折号,全角的、小号的也算;波浪形的不算),减号类符号(数学减号、上下标
|
||||
// 负号、修饰字母负号、粗减号),以及显示出来同样是一道横线的字:Unicode 易混字符表里映射到「-」「ー」的(汉字「一」、
|
||||
// 假名长音、制表符的横线这类),和在 Chrome 的常用字体里逐个画出来看是一道横线、高度和负号相当的字;
|
||||
// - sep:半角逗号、句点和长得一样的窄标点(低位单引号、单点前导符、小号的逗号句点这类,取自易混字符表):「1,」后面
|
||||
// 接着数字也算紧挨着。全角的「,」「.」不算:字宽,中文里两个数之间本来就常用「,」隔开;
|
||||
// - skip:显示时不占位置的字(零宽空格、连接符、变体选择符、韩文填充符这类格式字符和默认不显示的字),隔着它们照样
|
||||
// 算紧挨着;
|
||||
// - blur:显示成什么说不准的字:控制字符(制表、换行这几个空白除外)、未分配和私用区的字、孤立的代理项、组合附加符号
|
||||
// (叠在前一个字上:叠在空格上能在数字前面画出一道横线)、可选连字符(在它那里换行时显示成连字符)、发丝空格、对象
|
||||
// 替换符,以及在 Chrome 的常用字体里量出来几乎不占宽度的几个字。各家浏览器、字体把它们显示成数字、横线还是什么都
|
||||
// 不显示说不准:隔着它们照样往外找数字、负号;找不到的也判无法核对,原因另写;
|
||||
// - other:别的字(汉字、字母、空格、别的标点)把数字隔开。
|
||||
// 改变文字方向的控制字符(BIDI)和从右往左书写的文字(RTL:希伯来文、阿拉伯文这类)另算:它们能把一位数挪到数字的
|
||||
// 另一边显示(逻辑上在数字后面的「5」显示在数字前面),按字的先后看不出来,同一句里出现就判无法核对
|
||||
const DIGIT = unicode('^\\p{Nd}$') || /^[0-9\uff10-\uff19]$/
|
||||
const MINUS = /^[-\u2212\ufe63\uff0d]$/
|
||||
const INVISIBLE = unicode('^[\\p{Cf}\\p{Mn}\\p{Me}]$')
|
||||
|| /^[\u00ad\u0300-\u036f\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]$/
|
||||
const DASH_LIKE = '\\u02d7\\u0c7c\\u1173\\u172d\\u174d\\u180a\\u1b78\\u1bc7\\u1bf3\\u1c7c\\u2043\\u207b\\u208b\\u2212'
|
||||
+ '\\u23af\\u23ba-\\u23bd\\u23e4\\u2500\\u2501\\u2504\\u2505\\u2508\\u2509\\u254c\\u254d\\u2550\\u2574\\u2576\\u2578'
|
||||
+ '\\u257a\\u257c\\u257e\\u2796\\u29ff\\u2cba\\u2cbb\\u2d67\\u2f00\\u30fc\\u3127\\u3161\\u3192\\u31d0\\u4e00\\ua7f7'
|
||||
+ '\\uaa7d\\uff70\\uffda'
|
||||
const DASH_LIKE_ASTRAL = '\\u{10110}\\u{10191}\\u{11049}\\u{1104b}\\u{11052}\\u{110be}\\u{13404}\\u{1bc03}\\u{1bc08}'
|
||||
+ '\\u{1bc0d}\\u{1bc86}\\u{1d369}'
|
||||
const MINUS = unicode(`^(?![\\u301c\\u3030])[\\p{Pd}${DASH_LIKE}${DASH_LIKE_ASTRAL}]$`)
|
||||
|| new RegExp(`^[-\\u2010-\\u2015\\u2e3a\\u2e3b\\ufe58\\ufe63\\uff0d${DASH_LIKE}]$`)
|
||||
const SEP = unicode('^[.,\\u00b8\\u201a\\u2024\\u2e12\\ua4f8\\ua4f9\\ua60e\\uabec\\ufe50\\ufe52\\u{16f53}\\u{1d16d}]$')
|
||||
|| /^[.,\u00b8\u201a\u2024\u2e12\ua4f8\ua4f9\ua60e\uabec\ufe50\ufe52]$/
|
||||
// 变体选择符只换前一个字的字形(文字样式还是表情样式),算 skip:表情后面常带一个,不能因此把数字判成核对不了
|
||||
const VARIATION = unicode('^[\\u180b-\\u180d\\u180f\\ufe00-\\ufe0f\\u{e0100}-\\u{e01ef}]$')
|
||||
|| /^[\u180b-\u180d\u180f\ufe00-\ufe0f]$/
|
||||
const BLUR_EXTRA = '\\u00ad\\u02dd\\u1843\\u1cd3\\u200a\\u2044\\ufffc'
|
||||
const BLUR = unicode(`^(?![\\t\\n\\f\\r])[\\p{Cc}\\p{Cn}\\p{Co}\\p{Cs}\\p{Mn}\\p{Me}${BLUR_EXTRA}]$`)
|
||||
|| new RegExp('^(?![\\t\\n\\f\\r])[\\x00-\\x1f\\x7f-\\x9f\\u0300-\\u036f\\u0483-\\u0489\\u1ab0-\\u1aff\\u1dc0-\\u1dff'
|
||||
+ `\\u20d0-\\u20ff\\ud800-\\udfff\\ue000-\\uf8ff\\ufe20-\\ufe2f\\ufff0-\\ufff8${BLUR_EXTRA}]$`)
|
||||
const SKIP = unicode('^[\\p{Cf}\\p{Default_Ignorable_Code_Point}]$')
|
||||
|| /^[\u034f\u061c\u115f\u1160\u17b4\u17b5\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\u3164\ufeff\uffa0]$/
|
||||
const BIDI = /^[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]$/
|
||||
const RTL = unicode('^[\\u0590-\\u08ff\\ufb1d-\\ufdff\\ufe70-\\ufefe\\u{10800}-\\u{10fff}\\u{1e800}-\\u{1efff}]$')
|
||||
|| /^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefe]$/
|
||||
|
||||
/** 一个字(码点)算哪一类,见上 */
|
||||
function kindOf(c) {
|
||||
if (VARIATION.test(c)) return 'skip'
|
||||
if (DIGIT.test(c)) return 'digit'
|
||||
if (MINUS.test(c)) return 'minus'
|
||||
if (SEP.test(c)) return 'sep'
|
||||
if (BLUR.test(c)) return 'blur'
|
||||
return SKIP.test(c) ? 'skip' : 'other'
|
||||
}
|
||||
|
||||
const NOWHERE = { at: -1, kind: null, blurred: false }
|
||||
|
||||
/**
|
||||
* ⑤ 的最后一步:核对通过的数字、取值,读的人在正文里看到的就该是核对过的那串字。items 是这份报告的 片段 → 核对结果,
|
||||
* 每一项记下正文里显示出来的字(onPage)。
|
||||
* - 正文里根本不显示它(分隔线里、表格里不在各行各列上的格):伪造的字可以写在别处,读的人看不到核对过的那个数。
|
||||
* 服务端导出时不会出现(分隔线不带句子,表格的格都在行列上),判不通过;
|
||||
* - 紧挨着的字里还有数字或负号(前面补一位、后面接一位或一段小数、前面加负号;隔着看不见的字、显示时藏起来的排版
|
||||
* 标记也算紧挨着),或者同一句里有改变文字方向的控制字符:读起来的数不止核对过的这几位,判无法核对——真报告里
|
||||
* 也可能有(放行的数字、日期紧贴着引用),不能说成被改过。两个数之间的连字符是区间,不算负号。
|
||||
* - 紧挨着的字里还有数字或负号(前面补一位、后面接一位或一段小数、前面加负号;隔着不占位置的字、显示时藏起来的排版
|
||||
* 标记也算紧挨着),紧挨着的是显示成什么说不准的字,或者同一句里有改变文字方向的字:读起来的数可能不止核对过的
|
||||
* 这几位,判无法核对——真报告里也可能有(放行的数字、日期紧贴着引用),不能说成被改过。两个数之间的连字符是区间,
|
||||
* 不算负号。
|
||||
* 代码块按原文显示、不标数字,照旧
|
||||
*/
|
||||
function checkLayout(doc, items) {
|
||||
const numeric = (seg) => seg.kind === 'number' || seg.kind === 'value'
|
||||
const soft = (kind) => kind === 'skip' || kind === 'blur'
|
||||
for (const block of Array.isArray(doc.blocks) ? doc.blocks.filter(isObj) : []) {
|
||||
if (block.type === 'code') {
|
||||
for (const unit of Array.isArray(block.units) ? block.units.filter(isObj) : []) {
|
||||
@@ -836,43 +893,75 @@
|
||||
continue
|
||||
}
|
||||
for (const flow of flowsOf(block)) {
|
||||
// 这一组显示出来的字(按码点:数学字母区的数字占两个 UTF-16 单元),逐个记下属于哪个片段:藏起来的不显示,
|
||||
// 看不见的不参与判断。可点的数字自成一块,它里面的字改变不了外面的方向,只看别的片段里的方向控制字符
|
||||
const chars = []
|
||||
let turned = false
|
||||
// 这一组显示出来的字(藏起来的排版标记不算),逐个记下属于哪个片段
|
||||
const shownUnits = []
|
||||
for (const unit of flow) {
|
||||
const { segs, text, styles, at } = unitPlan(unit)
|
||||
segs.forEach((seg, i) => {
|
||||
let shown = ''
|
||||
for (let k = at[i][0]; k < at[i][1]; k++) {
|
||||
if (styles.hide[k]) continue
|
||||
let c = text[k]
|
||||
const code = text.charCodeAt(k)
|
||||
if (code >= 0xd800 && code < 0xdc00 && k + 1 < at[i][1]) c = text.slice(k, ++k + 1)
|
||||
shown += c
|
||||
if (BIDI.test(c) && !items.has(seg)) turned = true
|
||||
if (!INVISIBLE.test(c)) chars.push([c, seg])
|
||||
shown += text[k]
|
||||
shownUnits.push([text[k], seg])
|
||||
}
|
||||
if (items.has(seg)) items.get(seg).onPage = shown
|
||||
})
|
||||
}
|
||||
const ch = (k) => (k >= 0 && k < chars.length ? chars[k][0] : '')
|
||||
const digit = (k) => DIGIT.test(ch(k))
|
||||
const inNumber = (k) => k >= 0 && k < chars.length && numeric(chars[k][1])
|
||||
// 拼成码点:代理对的两半在页面上挨着就显示成一个字(数学字母区的数字这类),分在两个文字片段里、中间隔着
|
||||
// 藏起来的排版标记也一样;不成对的半个单独算一个字(显示成替换字符),不和后面的字拼。可点的数字自成一块,它的
|
||||
// 字不和外面的拼
|
||||
const chars = []
|
||||
for (let k = 0; k < shownUnits.length; k++) {
|
||||
const [c, seg] = shownUnits[k]
|
||||
const next = shownUnits[k + 1]
|
||||
const paired = next && c >= '\ud800' && c < '\udc00' && next[0] >= '\udc00' && next[0] < '\ue000'
|
||||
&& (next[1] === seg || (!items.has(seg) && !items.has(next[1])))
|
||||
chars.push(paired ? [c + shownUnits[++k][0], seg] : [c, seg])
|
||||
}
|
||||
// 可点的数字自成一块,它里面的字改变不了外面的方向:只看别的片段
|
||||
const turned = chars.some(([c, seg]) => !items.has(seg) && (BIDI.test(c) || RTL.test(c)))
|
||||
const kinds = chars.map(([c]) => kindOf(c))
|
||||
const spans = new Map()
|
||||
chars.forEach(([, seg], k) => {
|
||||
if (spans.has(seg)) spans.get(seg)[1] = k
|
||||
else spans.set(seg, [k, k])
|
||||
})
|
||||
// 从 k 往 step 方向找下一个看得见的字:skip 跳过,blur 也越过去、记下来。{ at, kind, blurred }
|
||||
const look = (k, step) => {
|
||||
let blurred = false
|
||||
for (let j = k + step; j >= 0 && j < chars.length; j += step) {
|
||||
if (kinds[j] === 'skip') continue
|
||||
if (kinds[j] === 'blur') {
|
||||
blurred = true
|
||||
continue
|
||||
}
|
||||
return { at: j, kind: kinds[j], blurred }
|
||||
}
|
||||
return { ...NOWHERE, blurred }
|
||||
}
|
||||
for (const [seg, item] of items) {
|
||||
if (item.result !== PASS || !spans.has(seg)) continue
|
||||
const [first, last] = spans.get(seg)
|
||||
const before = digit(first) && (digit(first - 1) || (/^[.,]$/.test(ch(first - 1)) && digit(first - 2)))
|
||||
const sign = (digit(first) || MINUS.test(ch(first))) && MINUS.test(ch(first - 1))
|
||||
&& !digit(first - 2) && !inNumber(first - 2)
|
||||
const after = digit(last) && (digit(last + 1) || (/^[.,]$/.test(ch(last + 1)) && digit(last + 2)))
|
||||
if (turned || before || sign || after) {
|
||||
Object.assign(item, { result: NA, reason: T.numShownUnknown(turned ? T.shownTurned : T.shownGlued) })
|
||||
const [s0, s1] = spans.get(seg)
|
||||
// 片段自己的头一个、末一个字:越过它自己头尾不占位置、说不准的字(取值里本来就带的组合符号这类)
|
||||
let first = s0
|
||||
while (first < s1 && soft(kinds[first])) first++
|
||||
let last = s1
|
||||
while (last > first && soft(kinds[last])) last--
|
||||
const lead = kinds[first] === 'digit' || kinds[first] === 'minus'
|
||||
const tail = kinds[last] === 'digit'
|
||||
const l1 = look(s0, -1)
|
||||
const l2 = l1.at >= 0 ? look(l1.at, -1) : NOWHERE
|
||||
const r1 = look(s1, 1)
|
||||
const r2 = r1.at >= 0 ? look(r1.at, 1) : NOWHERE
|
||||
const before = kinds[first] === 'digit' && (l1.kind === 'digit' || (l1.kind === 'sep' && l2.kind === 'digit'))
|
||||
const sign = lead && l1.kind === 'minus' && l2.kind !== 'digit' && !(l2.at >= 0 && numeric(chars[l2.at][1]))
|
||||
const after = tail && (r1.kind === 'digit' || (r1.kind === 'sep' && r2.kind === 'digit'))
|
||||
// 数字头尾紧挨着(或隔着一个逗号、句点)的是说不准的字:它本身就可能显示成数字、负号
|
||||
const near = (a, b) => a.blurred || (a.kind === 'sep' && b.blurred)
|
||||
const blurred = (lead && near(l1, l2)) || (tail && near(r1, r2))
|
||||
if (turned || before || sign || after || blurred) {
|
||||
const why = turned ? T.shownTurned : before || sign || after ? T.shownGlued : T.shownBlurred
|
||||
Object.assign(item, { result: NA, reason: T.numShownUnknown(why) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1436,15 +1525,15 @@
|
||||
// 表头 row = -1;一格可能被分成几句,按位置归拢(tableCells)
|
||||
const { cells, rows: rowIds, cols } = tableCells(units)
|
||||
const textAt = (r, c) => (cells.get(`${r}:${c}`) || []).map(unitText).join('').replace(/[*`]/g, '').trim()
|
||||
const numeric = Array.from({ length: cols }, (_, c) => {
|
||||
const numeric = new Set(cols.filter((c) => {
|
||||
const texts = rowIds.map((r) => textAt(r, c)).filter(Boolean)
|
||||
return texts.length > 0 && texts.every((t) => NUMERIC_CELL.test(t))
|
||||
})
|
||||
const cell = (tag, r, c) => h(tag, { class: tag === 'td' && numeric[c] ? 'n' : null },
|
||||
}))
|
||||
const cell = (tag, r, c) => h(tag, { class: tag === 'td' && numeric.has(c) ? 'n' : null },
|
||||
(cells.get(`${r}:${c}`) || []).map(inline))
|
||||
return h('div', { class: 'table-wrap' }, h('table', {},
|
||||
h('thead', {}, h('tr', {}, Array.from({ length: cols }, (_, c) => cell('th', -1, c)))),
|
||||
h('tbody', {}, rowIds.map((r) => h('tr', {}, Array.from({ length: cols }, (_, c) => cell('td', r, c)))))))
|
||||
h('thead', {}, h('tr', {}, cols.map((c) => cell('th', -1, c)))),
|
||||
h('tbody', {}, rowIds.map((r) => h('tr', {}, cols.map((c) => cell('td', r, c)))))))
|
||||
}
|
||||
default:
|
||||
return h('p', {}, units.map(inline))
|
||||
@@ -1769,7 +1858,7 @@
|
||||
// 在 node 里载入(tests/test_bundle_shown.py 拿服务端的渲染逐条对照、整份核对):没有页面,交出核对用的纯函数就停
|
||||
if (typeof document === 'undefined') {
|
||||
if (typeof module === 'object' && module) {
|
||||
module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown }
|
||||
module.exports = { verify, renderNumber, renderCell, numberTokens, tokenAt, parseJson, Unshown, listItems, tableCells }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
①–④ 照样对得上,⑤ 里那一项不通过,原因点名「正文显示「X」,引用的值是「Y」」;认不出的换算判无法核对,不判通过;
|
||||
- 口径卡单位前后带空白(「元 」,服务端照原样接):两边都只去首尾空白,原样导出照样通过;运行输入照旧无法核对,显示
|
||||
文字拿引用的值核对,只改显示文字的不通过;出处面板里数字文本照原文写;
|
||||
- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过,紧挨着补数字、
|
||||
负号、方向控制字符的判无法核对,区间、日期这类真报告里的写法照常通过。
|
||||
- 正文的排法:核对通过的字要原样显示——伪造的链接藏不起它(整句按原文显示),正文里不显示的判不通过;紧挨着补数字、
|
||||
负号(长得像负号的横线也算,隔着不占位置的字也算),紧挨着显示成什么说不准的字(控制字符、组合符号这类),同一句里
|
||||
有改变文字方向的字(方向控制字符、希伯来文和阿拉伯文这类从右往左书写的文字),判无法核对;区间、日期这类真报告里的
|
||||
写法照常通过;列表缩进有上限、表格只排有格的列,伪造的缩进、列号挤不走核对过的数。
|
||||
|
||||
数据全是虚构的。
|
||||
"""
|
||||
@@ -65,6 +67,11 @@ function render(c) {
|
||||
|
||||
async function main() {
|
||||
if (input.task === 'render') return input.cases.map(render)
|
||||
// 列表各项显示的缩进级数、表格排出来的列号(核对和显示共用的那两个函数)
|
||||
if (input.task === 'layout') {
|
||||
return input.blocks.map((b) => (b.type === 'list' ? v.listItems(b.units).map((i) => i.depth)
|
||||
: v.tableCells(b.units).cols))
|
||||
}
|
||||
const digest = async (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
const out = await v.verify(input.data, digest, input.seal)
|
||||
const results = (items) => items.map((i) => i.result)
|
||||
@@ -244,8 +251,12 @@ def segment(doc: dict[str, Any], ref: str) -> dict[str, Any]:
|
||||
|
||||
def reseal(bundle: dict[str, Any], art: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""改过的文档重新落成工件:文档 id、引用它的事件(规范字符串和哈希)、封存清单哈希全部重算。和服务端写工件一样
|
||||
用规范 JSON:① 到 ④ 都对得上,伪造只可能在 ⑤ 露馅。"""
|
||||
raw = canonical_json(doc).encode()
|
||||
用规范 JSON:① 到 ④ 都对得上,伪造只可能在 ⑤ 露馅。伪造者还能写进不成对的代理项(JSON 里的 \\ud835 转义),
|
||||
规范 JSON 编不成 UTF-8:这时照 JSON 的转义写,页面只看字节的哈希,照样对得上。"""
|
||||
try:
|
||||
raw = canonical_json(doc).encode()
|
||||
except UnicodeEncodeError:
|
||||
raw = json.dumps(doc, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode()
|
||||
old, new = art["id"], sha(raw)
|
||||
art["id"], art["b64"] = new, base64.b64encode(raw).decode()
|
||||
for report in bundle["reports"]:
|
||||
@@ -536,6 +547,11 @@ def test_inline_markup_cannot_hide_a_checked_value():
|
||||
|
||||
|
||||
GLUED = "无法核对显示文字:紧挨着的正文里还有数字或负号,读起来和核对过的不是同一个值"
|
||||
BLURRED = "无法核对显示文字:紧挨着的正文里有看不见或在不同设备上显示不同的字,读起来可能和核对过的不是同一个值"
|
||||
TURNED = "无法核对显示文字:这一句里有从右往左书写的文字或改变文字方向的控制字符,显示出来的顺序可能和核对过的不一样"
|
||||
#: 经营月报第一段里的数字(第二段是 Q1 的取值)
|
||||
FIRST = {"m:gmv", "m:gmv|万", "m:annual|亿", "m:refund", "m:conv_rate", "m:growth", "m:orders", "m:avg", "m:avg|.3",
|
||||
"m:avg|int", "m:share|pct", "m:export"}
|
||||
|
||||
|
||||
def _glue_before(seg_id: str, extra: str, after: str = ""):
|
||||
@@ -553,21 +569,51 @@ def _glue_after(seg_id: str, extra: str):
|
||||
return change
|
||||
|
||||
|
||||
def _split_pair(high: str, low: str):
|
||||
"""销售额前面插两个文字片段:前一个以代理对的前半结尾,后一个是后半。页面上两半挨着,拼成一个字显示。"""
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
unit = next(u for b in doc["blocks"] for u in b["units"] if any(s.get("id") == "s4" for s in u["segments"]))
|
||||
at = next(i for i, s in enumerate(unit["segments"]) if s.get("id") == "s4")
|
||||
by_id(doc, "s3")["text"] += high
|
||||
unit["segments"].insert(at, {"kind": "text", "id": "s3b", "text": low, "state": "neutral"})
|
||||
return change
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref, change", [
|
||||
("m:gmv", _glue_before("s3", "1")), # 前面多一位:174,613.65元
|
||||
("m:gmv", _glue_before("s3", "1\u200b")), # 隔着看不见的零宽空格
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x200B))), # 隔着看不见的零宽空格
|
||||
("m:gmv", _glue_before("s3", "1**", after="**")), # 隔着排版时藏起来的粗体标记
|
||||
("m:gmv", _glue_before("s3", chr(0x1D7F7))), # 数学字母区的数字 1(占两个 UTF-16 单元)
|
||||
# 隔着显示时不占位置的字(Chrome 里量过,宽 0 到 1px 不到):控制字符 DEL、未分配的默认不显示字、韩文填充符、
|
||||
# 对象替换符、发丝空格、蒙古文里一个宽 0 的字、叠在数字上的组合符号、变体选择符
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x7F))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0xFFF0))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x115F))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0xFFFC))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x200A))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x1843))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x0301))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0xFE0F))),
|
||||
("m:gmv", _glue_before("s3", "1" + chr(0x201A))), # 和半角逗号长得一样的低位单引号(U+201A)
|
||||
("m:gmv", _glue_before("s3", chr(0xD800) + "1")), # 不成对的代理项不和后面的 1 拼成一个字
|
||||
("m:gmv", _split_pair(chr(0xD835), chr(0xDFF7))), # 代理对分在两个文字片段里,页面上照样拼成数字 1
|
||||
("v:Q1.r0.gmv", _glue_after("s34", "0")), # 后面多一位:45,678.50
|
||||
("v:Q1.r0.gmv", _glue_after("s34", ".5")), # 后面接小数:45,678.5.5
|
||||
("v:Q1.r0.gmv", _glue_after("s34", chr(0x2024) + "5")), # 和句点长得一样的单点前导符
|
||||
("m:conv_rate", _glue_before("s12", "-")), # 前面加负号:-2.35%
|
||||
("m:conv_rate", _glue_before("s12", "\uff0d")), # 全角负号
|
||||
("m:conv_rate", _glue_before("s12", chr(0xFF0D))), # 全角负号
|
||||
# 长得像负号的横线:连字符、不断行连字符、数字宽的横线、短破折号、长破折号、修饰字母负号、粗减号、小号长破折号、
|
||||
# 数学减号、上标负号,和显示出来同样是一道横线的汉字「一」、假名长音、制表符横线、罗马盎司符号(码点在两个
|
||||
# UTF-16 单元里)
|
||||
*[("m:conv_rate", _glue_before("s12", chr(cp))) for cp in (
|
||||
0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x02D7, 0x2796, 0xFE58, 0x2212, 0x207B, 0x4E00, 0x30FC, 0x2500,
|
||||
0x10191)],
|
||||
("m:refund", _glue_before("s9", "-")), # 负数前面再加负号:--3,120.50元
|
||||
("v:Q1.r0.paid_at", _glue_after("s45", "5")), # 取值以数字结尾:2026-09-07 08:21:395
|
||||
])
|
||||
def test_digits_glued_to_a_checked_value_cannot_pass(ref, change):
|
||||
"""伪造者在相邻的文字片段里补一位数、一个负号(隔着零宽空格、藏起来的粗体标记也算紧挨着):显示出来的数不止
|
||||
核对过的这几位,这一项判无法核对、写明原因;别的照常通过。"""
|
||||
"""伪造者在相邻的文字片段里补一位数、一个负号(隔着不占位置、显示成什么说不准的字,藏起来的粗体标记也算紧挨着;
|
||||
长得像负号的横线也算负号):显示出来的数不止核对过的这几位,这一项判无法核对、写明原因;别的照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
@@ -576,33 +622,75 @@ def test_digits_glued_to_a_checked_value_cannot_pass(ref, change):
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
|
||||
|
||||
|
||||
def test_direction_controls_next_to_checked_values_cannot_pass():
|
||||
"""文字方向控制字符(从右往左覆盖)能把后面的一位数挪到数字前面显示,按字的先后看不出紧挨着:同一句里出现在
|
||||
别的片段里,这一句的数字、取值判无法核对;别的段照常通过。"""
|
||||
@pytest.mark.parametrize("ref, change", [
|
||||
("m:gmv", _glue_before("s3", chr(0x0336))), # 叠在空格上的长横线:页面上像一个负号
|
||||
("m:gmv", _glue_before("s3", chr(0x82))), # 控制字符,Chrome 里画成一道短横
|
||||
("m:gmv", _glue_before("s3", chr(0x7F))), # 控制字符 DEL,什么都不显示
|
||||
("m:gmv", _glue_before("s3", chr(0xAD))), # 可选连字符:在它那里换行时显示成连字符
|
||||
("m:gmv", _glue_before("s3", chr(0xE000))), # 私用区的字:各家字体画成什么都有
|
||||
("v:Q1.r0.gmv", _glue_after("s34", chr(0x0378))), # 未分配的码点紧跟在数字后面
|
||||
("v:Q1.r0.gmv", _glue_after("s34", "." + chr(0x7F))), # 隔着句点紧跟一个说不准的字
|
||||
])
|
||||
def test_characters_that_may_show_as_anything_next_to_a_value_cannot_pass(ref, change):
|
||||
"""紧挨着数字的是显示成什么说不准的字(控制字符、组合符号、可选连字符、私用区和未分配的字):各家浏览器、字体
|
||||
可能把它显示成数字、横线或者什么都不显示,判无法核对,原因说清是这类字;别的照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
hit = item(got, ref)
|
||||
assert hit["result"] == "na" and hit["reason"] == BLURRED and hit["shownOk"], hit
|
||||
assert all(n["result"] == "pass" for n in got["numbers"] if n["ref"] != ref)
|
||||
|
||||
|
||||
def test_uncertain_characters_where_they_cannot_change_the_value_still_pass():
|
||||
"""说不准的字挨着的不是数字的头尾(销售额以「元」结尾,后面跟一个控制字符),表情后面的变体选择符,数字前面的波浪线,
|
||||
都改不了读到的数:照常通过。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
by_id(doc, "s5")["text"] = chr(0x202E) + by_id(doc, "s5")["text"]
|
||||
by_id(doc, "s5")["text"] = chr(0x7F) + chr(0x0336) + by_id(doc, "s5")["text"]
|
||||
by_id(doc, "s12")["text"] += chr(0x2705) + chr(0xFE0F) # 转化率紧前面:对勾表情加上表情样式的变体选择符
|
||||
by_id(doc, "s14")["text"] += chr(0x301C) # 环比紧前面:波浪线不是横线,不算负号
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change, scope", [
|
||||
(lambda doc: by_id(doc, "s5").update(text=chr(0x202E) + by_id(doc, "s5")["text"]), FIRST), # 从右往左覆盖
|
||||
# 希伯来字母:逻辑上在销售额后面的「5」显示在销售额前面(页面上读作 574,613.65元)
|
||||
(lambda doc: (by_id(doc, "s3").update(text=by_id(doc, "s3")["text"] + chr(0x05D0)),
|
||||
by_id(doc, "s5").update(text="5" + by_id(doc, "s5")["text"])), FIRST),
|
||||
# 希伯来字母后面的「5」显示在 45,678.50 紧后面(读作 45,678.505);阿拉伯字母同样
|
||||
(lambda doc: by_id(doc, "s34").update(text=chr(0x05D0) + "5" + by_id(doc, "s34")["text"]), None),
|
||||
(lambda doc: by_id(doc, "s34").update(text=chr(0x0628) + "5" + by_id(doc, "s34")["text"]), None),
|
||||
# 腓尼基字母(从右往左书写,码点在两个 UTF-16 单元里)
|
||||
(lambda doc: by_id(doc, "s34").update(text=chr(0x10900) + "5" + by_id(doc, "s34")["text"]), None),
|
||||
])
|
||||
def test_text_that_changes_direction_next_to_checked_values_cannot_pass(change, scope):
|
||||
"""改变文字方向的控制字符、从右往左书写的文字(希伯来文、阿拉伯文这类)能把后面的一位数挪到数字的另一边显示,
|
||||
按字的先后看不出紧挨着:同一段里出现在别的片段里,这一段的数字、取值判无法核对;别的段照常通过。scope 是受影响的
|
||||
那一段(None:经营月报第二段,Q1 的取值)。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(), change))
|
||||
first = {"m:gmv", "m:gmv|万", "m:annual|亿", "m:refund", "m:conv_rate", "m:growth", "m:orders", "m:avg",
|
||||
"m:avg|.3", "m:avg|int", "m:share|pct", "m:export"}
|
||||
for n in got["numbers"]:
|
||||
if n["ref"] in first:
|
||||
assert n["result"] == "na" and n["reason"] == ("无法核对显示文字:这一句里有改变文字方向的控制字符,"
|
||||
"显示出来的顺序可能和核对过的不一样"), n
|
||||
inside = n["ref"] in scope if scope is not None else n["ref"] not in FIRST
|
||||
if inside:
|
||||
assert n["result"] == "na" and n["reason"] == TURNED, n
|
||||
else:
|
||||
assert n["result"] == "pass", n
|
||||
|
||||
|
||||
def test_ranges_dates_and_signs_written_by_the_system_still_pass():
|
||||
"""真报告里常见的写法不算紧挨着:两个数之间的连字符(区间)、日期和数字之间隔着字或空白、负数自己的负号。"""
|
||||
"""真报告里常见的写法不算紧挨着:两个数之间的连字符、破折号(区间)、日期和数字之间隔着字或空白、负数自己的负号。"""
|
||||
from tests.bundle_samples import formats_bundle
|
||||
|
||||
text = ("## 区间\n\n销售额区间 [[m:gmv]]-[[m:export]],流水区间 [[v:Q1.r0.gmv]]-[[v:Q1.r1.gmv]]。"
|
||||
"截至2026年9月[[m:orders]],2026-09-07 当天客单价 [[m:avg]],环比 [[m:growth]]。")
|
||||
"截至2026年9月[[m:orders]],2026-09-07 当天客单价 [[m:avg]],环比 [[m:growth]],"
|
||||
"退款区间 [[m:refund]]" + chr(0x2014) + "[[m:annual|亿]]。")
|
||||
got = verdicts(formats_bundle(text=text))
|
||||
assert len(got["numbers"]) == 7
|
||||
assert len(got["numbers"]) == 9
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
|
||||
|
||||
|
||||
@@ -652,6 +740,39 @@ def test_a_table_cell_outside_the_rows_and_columns_fails():
|
||||
assert n["result"] == "pass", n
|
||||
|
||||
|
||||
def _list_unit(depth: Any) -> dict[str, Any]:
|
||||
return {"segments": [{"kind": "structural", "text": "- "}, {"kind": "text", "text": "一项"}], "depth": depth}
|
||||
|
||||
|
||||
def test_list_indentation_is_capped_on_screen():
|
||||
"""列表照级数缩进(每级 14px),最多 8 级:服务端按原文的缩进算级数、没有上限,伪造的级数写成十万或负数,那一项
|
||||
也留在屏幕上(读的人看得到核对过的数,窄屏不横向溢出)。核对和显示用的是同一份级数(listItems)。"""
|
||||
depths = [0, 1, 3, 8, 9, 100000, -1, -100000, 2.7, "x", None, "4"]
|
||||
got = node("layout", blocks=[{"type": "list", "units": [_list_unit(d) for d in depths]}])
|
||||
assert got == [[0, 1, 3, 8, 8, 8, 0, 0, 2, 0, 0, 4]]
|
||||
|
||||
|
||||
def test_table_columns_without_cells_are_not_laid_out():
|
||||
"""表格只排有格的列:服务端每行从第 0 列起一格不落,列号连着;伪造的列号写成十万,那一格紧接着前一列显示,不会
|
||||
被几万个空列挤到表格右边看不见的地方。列号不是非负整数的格照旧不排(判不通过,见上一条)。"""
|
||||
units = [{"loc": {"row": r, "col": c}, "segments": []}
|
||||
for r, c in [(-1, 0), (-1, 1), (0, 0), (0, 1), (0, 100000), (1, 1.5), (1, -1), (1, "x"), (1, "2")]]
|
||||
assert node("layout", blocks=[{"type": "table", "units": units}]) == [[0, 1, 2, 100000]]
|
||||
|
||||
|
||||
def test_a_huge_column_number_neither_hides_nor_breaks_the_check():
|
||||
"""列号写成 2^32:以前按列号建空列,数组长度超出上限,整段核对出错;现在那一格紧接着显示、照常核对通过。"""
|
||||
from tests.bundle_samples import FORMATS, formats_bundle
|
||||
|
||||
def change(doc: dict[str, Any]) -> None:
|
||||
table = next(b for b in doc["blocks"] if b["type"] == "table")
|
||||
unit = next(u for u in table["units"] if u.get("loc") == {"row": 1, "col": 1})
|
||||
unit["loc"] = {"row": 1, "col": 2 ** 32}
|
||||
|
||||
got = verdicts(rewritten(formats_bundle(text=FORMATS + "\n\n[[table:Q1 cols=region,gmv rows=0-1]]"), change))
|
||||
assert all(n["result"] == "pass" and n["onPage"] == n["text"] for n in got["numbers"]), got["numbers"]
|
||||
|
||||
|
||||
def test_reasons_for_readers_do_not_name_implementation_details():
|
||||
"""核对页是发给业务读者的文件:「无法核对显示文字」的原因说清是什么情况、该怎么办,不提 BigInt、正则、ASCII。"""
|
||||
source = VERIFIER.read_text(encoding="utf-8")
|
||||
|
||||
+114
-1
@@ -21,7 +21,9 @@
|
||||
// 判定、服务端缺文件、裸 NaN、别的运行的文档、口径卡有输入取自遮罩列、设了遮罩的数据源上按地区汇总的查询、每种显示
|
||||
// 格式各引用一处的经营月报、单位带尾随空白的经营月报),逐份断言各项结果和原因文案;经营月报上只改文字取值、数字、
|
||||
// 换算成万的口径卡引用的显示文字并重算全部哈希,各自变红并点名;核对过的字不动、改它旁边的(链接地址藏起数字、前面
|
||||
// 补一位数、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;在上面篡改 ④
|
||||
// 补一位数、隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、从右往左书写的字母把一位数
|
||||
// 挪到数字前面、整段改成分隔线、文字片段冒用数字的 id),读的人看到的数要就是核对过的那个;列表缩进写成十万级或负数、
|
||||
// 表格的列号写成十万,核对通过的数字照样留在屏幕上(宽屏、窄屏都不横向溢出);在上面篡改 ④
|
||||
// 引用链(换文档、拿掉报告、多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把
|
||||
// 报告文档标成遮罩、把遮罩卡标成服务端的文件与 id 不一致或解析不出来(服务端对被改过、解析不了的遮罩卡就这么标)、
|
||||
// 遮罩源上随文件附带的汇总快照改一个字节、它的数字改引用值并重算全部哈希、
|
||||
@@ -1017,6 +1019,117 @@ await section('样本页:篡改', async () => {
|
||||
&& !s.buttons.some((b) => b.text.includes('84,613.65')), JSON.stringify(same)],
|
||||
]
|
||||
}, { seal: true })
|
||||
// 紧挨着的字(经营月报,重算全部哈希):隔着显示时不占位置的控制字符补一位数、长得像负号的连字符、叠在空格上的横线、
|
||||
// 从右往左书写的字母把一位数挪到数字前面——读起来都不是核对过的那个数,判无法核对并写明原因。字一律按码点写,脚本
|
||||
// 里不出现看不见、改变方向的字
|
||||
const cp = (...points) => String.fromCodePoint(...points)
|
||||
const naBecause = (why) => (s, forged) => {
|
||||
const btn = buttonOf(s, forged)
|
||||
const item = s.items.find((i) => i.check === 'numbers' && i.result === 'na' && i.text.includes(why))
|
||||
return [
|
||||
['那个数字判无法核对,不再标通过', btn?.result === 'na', JSON.stringify(btn)],
|
||||
[`⑤ 写明「${why}」`, !!item, item?.text ?? '没有这一项'],
|
||||
['总览不再是「全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过', `${s.overall}:${s.text}`],
|
||||
]
|
||||
}
|
||||
const GLUED = '紧挨着的正文里还有数字或负号'
|
||||
await tamper(samples.formats, '隔着显示时不占位置的控制字符补一位数', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += `1${cp(0x7f)}`
|
||||
return { what: '销售额前面补「1」和一个 DEL', seg: segById(doc, 's4') }
|
||||
}), naBecause(GLUED), { seal: true })
|
||||
await tamper(samples.formats, '前面加一个长得像负号的连字符', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's12').text += cp(0x2010)
|
||||
return { what: '转化率前面加 U+2010', seg: segById(doc, 's13') }
|
||||
}), naBecause(GLUED), { seal: true })
|
||||
await tamper(samples.formats, '叠在空格上的横线', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += cp(0x336)
|
||||
return { what: '销售额前面的空格上叠一道长横线(U+0336)', seg: segById(doc, 's4') }
|
||||
}), naBecause('紧挨着的正文里有看不见或在不同设备上显示不同的字'), { seal: true })
|
||||
// 希伯来字母:逻辑上在销售额后面的「5」显示在销售额紧前面(读作 574,613.65元)。先量一下页面上确实这样显示,
|
||||
// 说明这种伪造能把数读错
|
||||
await tamper(samples.formats, '从右往左书写的字母把一位数挪到数字前面', (data) => rewriteDoc(data, (doc) => {
|
||||
segById(doc, 's3').text += cp(0x5d0)
|
||||
segById(doc, 's5').text = `5${segById(doc, 's5').text}`
|
||||
return { what: '销售额前面加一个希伯来字母、后面加「5」', seg: segById(doc, 's4') }
|
||||
}), async (s, forged, page) => {
|
||||
const gap = await page.evaluate((key) => {
|
||||
const btn = document.querySelector(`button.num[data-num="${key}"]`)
|
||||
const text = btn?.nextSibling?.firstChild
|
||||
if (!btn || !text || text.nodeType !== 3 || !text.data.startsWith('5')) return null
|
||||
const range = document.createRange()
|
||||
range.setStart(text, 0)
|
||||
range.setEnd(text, 1)
|
||||
return Math.round(btn.getBoundingClientRect().left - range.getBoundingClientRect().right)
|
||||
}, `${forged.node}|${forged.seg.id}`)
|
||||
return [
|
||||
['页面上「5」显示在销售额紧前面', gap !== null && Math.abs(gap) <= 2, `间距 ${gap}px`],
|
||||
...naBecause('这一句里有从右往左书写的文字')(s, forged),
|
||||
]
|
||||
}, { seal: true })
|
||||
// 列表缩进写得极大或写成负数(服务端按原文的缩进算级数、没有上限):最多缩进 8 级,列表里核对通过的数字都留在屏幕上、
|
||||
// 照常通过,宽屏、窄屏都不横向溢出
|
||||
for (const [label, depths] of [['列表缩进写成十万级', [100000, 3]], ['列表缩进写成负数', [-100000, -3]]]) {
|
||||
const data = structuredClone(dataOf(samples.formats))
|
||||
rewriteDoc(data, (doc) => {
|
||||
const block = doc.blocks[2]
|
||||
block.type = 'list'
|
||||
block.units.forEach((unit, i) => {
|
||||
unit.depth = depths[i]
|
||||
if (i > 0) unit.segments.unshift({ kind: 'structural', id: `li${i}`, text: '\n- ' })
|
||||
})
|
||||
return { what: label }
|
||||
})
|
||||
for (const width of [1280, 390]) {
|
||||
const opened = await openFile(writePage(`tampered-depth-${depths[0]}-${width}.html`,
|
||||
withSeal(withData(samples.formats, data), data.meta.manifest_hash)), { width })
|
||||
try {
|
||||
const s = await stateOf(opened.page)
|
||||
const boxes = await opened.page.evaluate(() => [...document.querySelectorAll('.doc li button.num')].map((b) => {
|
||||
const r = b.getBoundingClientRect()
|
||||
return [Math.round(r.left), Math.round(r.right)]
|
||||
}))
|
||||
check(`篡改${label}(${width} 宽):列表里的数字都在屏幕上、照常通过,页面不横向溢出`,
|
||||
boxes.length === 7 && boxes.every(([l, r]) => l >= 0 && r <= width) && s.overflow <= 0
|
||||
&& s.overall === 'pass' && s.text === '全部通过',
|
||||
`${JSON.stringify(boxes)};溢出 ${s.overflow}px;${s.overall}:${s.text}`)
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 表格里一格的列号写成十万:只排有格的列,那一格紧接着前一列显示,不会被几万个空列挤到表格右边,也不建几十万个空格
|
||||
{
|
||||
const data = structuredClone(dataOf(samples.base))
|
||||
const done = rewriteDoc(data, (doc) => {
|
||||
const table = doc.blocks.find((b) => b.type === 'table')
|
||||
const unit = table?.units.find((u) => u.loc?.row === 0 && u.loc?.col === 1)
|
||||
const seg = unit?.segments.find((x) => x.kind === 'number' || x.kind === 'value')
|
||||
if (!seg) return null
|
||||
unit.loc = { row: 0, col: 100000 }
|
||||
return { what: '表格第一行第二格的列号改成 100000', seg }
|
||||
})
|
||||
if (!done) {
|
||||
check('篡改表格的列号:样本页 base 里找不到表格第一行第二格的数字', false)
|
||||
} else {
|
||||
const opened = await openFile(writePage('tampered-table-col.html', withSeal(withData(samples.base, data),
|
||||
data.meta.manifest_hash)))
|
||||
try {
|
||||
const got = await opened.page.evaluate((key) => {
|
||||
const btn = document.querySelector(`button.num[data-num="${key}"]`)
|
||||
const wrap = btn?.closest('.table-wrap')
|
||||
if (!btn || !wrap) return null
|
||||
const b = btn.getBoundingClientRect()
|
||||
const w = wrap.getBoundingClientRect()
|
||||
return { result: btn.dataset.result, visible: b.left >= w.left && b.right <= w.left + wrap.clientWidth,
|
||||
cols: wrap.querySelectorAll('thead th').length, cells: wrap.querySelectorAll('td').length }
|
||||
}, `${done.node}|${done.seg.id}`)
|
||||
check('篡改表格的列号:那一格紧接着前一列显示、照常通过,表格只有 3 列',
|
||||
got?.result === 'pass' && got.visible && got.cols === 3 && got.cells === 24, JSON.stringify(got))
|
||||
} finally {
|
||||
await opened.ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 记录页的导出按钮
|
||||
|
||||
Reference in New Issue
Block a user