release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
|
||||
import {
|
||||
comparePdfSemanticBlockVisuals,
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
@@ -28,7 +30,289 @@ function metrics(
|
||||
};
|
||||
}
|
||||
|
||||
function whitePageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
context.fillStyle = "#111";
|
||||
context.fillRect(40, 40, 120, 12);
|
||||
context.fillRect(40, 80, 120, 12);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "test",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
function blankPageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "blank",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("缺少局部视觉行时必须阻断而不能静默通过", async () => {
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [] } as never,
|
||||
{ pages: [] } as never,
|
||||
[{
|
||||
expectation: { index: 39, section: "body", blockKind: "table-cell" },
|
||||
baseline: { matched: true, visualLines: [], lineTexts: [] },
|
||||
candidate: { matched: true, visualLines: [], lineTexts: [] },
|
||||
}] as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("failed");
|
||||
expect(comparison?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: whitePageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 120, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 7,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40), visualLine(80)],
|
||||
lineTexts: ["甲乙", "丙丁"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["甲乙丙丁"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(2);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
expect(comparison?.lines.every((line) =>
|
||||
line.issues.some((issue) => issue.details?.textReflow === true)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 118,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 10,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40), visualLine(70)],
|
||||
lineTexts: ["高血压预测模型目标", "值"],
|
||||
},
|
||||
candidate: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 9,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["高血压预测模型目标"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = {
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y: 40, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Serif"],
|
||||
};
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 71,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13],
|
||||
},
|
||||
baseline: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 12,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 14,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥90"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number, width: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 117,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)],
|
||||
lineTexts: ["记", "录/", "计算"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)],
|
||||
lineTexts: ["记录", "/计", "算"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(5);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
@@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => {
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 56,
|
||||
candidateWidthPx: 56,
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 56,
|
||||
comparedHeightPx: 37,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 17,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 17,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
const neutralSingleStrokeMetrics = metrics({
|
||||
baselineWidthPx: 37,
|
||||
candidateWidthPx: 37,
|
||||
baselineHeightPx: 27,
|
||||
candidateHeightPx: 27,
|
||||
comparedWidthPx: 37,
|
||||
comparedHeightPx: 27,
|
||||
meanAbsoluteError: 12.78,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 10.54,
|
||||
foregroundColorDelta: 108.69,
|
||||
baselineForegroundChroma: 10,
|
||||
candidateForegroundChroma: 5,
|
||||
foregroundLuminanceDelta: 109.27,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
neutralSingleStrokeMetrics,
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
@@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => {
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
comparedWidthPx: 262,
|
||||
comparedHeightPx: 30,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
@@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => {
|
||||
const paletteMetrics = metrics({
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 35,
|
||||
baselineForegroundChroma: 141,
|
||||
candidateForegroundChroma: 176,
|
||||
dominantForegroundColorDelta: 0.5,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, dominantForegroundColorDelta: 4.1 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, baselineForegroundChroma: 79 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => {
|
||||
const syntaxMetrics = metrics({
|
||||
baselineWidthPx: 407,
|
||||
candidateWidthPx: 403,
|
||||
baselineHeightPx: 22,
|
||||
candidateHeightPx: 23,
|
||||
inkIou: 0.988,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 92,
|
||||
chromaticPaletteDelta: 0.8,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, chromaticPaletteDelta: 2.1 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, foregroundInkIou: 0.994 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => {
|
||||
const inlineCodeMetrics = metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 234,
|
||||
baselineHeightPx: 24,
|
||||
candidateHeightPx: 25,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 25,
|
||||
inkIou: 0.91759,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.317,
|
||||
foregroundColorDelta: 3.158,
|
||||
});
|
||||
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
inlineCodeMetrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, foregroundColorDelta: 3.26 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, backgroundColorDelta: 12.01 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.99,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.97,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
@@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => {
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 2.01,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 5.01,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 5.01,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
|
||||
Reference in New Issue
Block a user