274 lines
8.5 KiB
JavaScript
274 lines
8.5 KiB
JavaScript
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
export const DOCX_LAYOUT_MATRIX_ORIENTATIONS = Object.freeze([
|
||
"portrait",
|
||
"landscape"
|
||
]);
|
||
|
||
export const DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS = Object.freeze([
|
||
Object.freeze({
|
||
id: "theme-default",
|
||
label: "主题默认边距",
|
||
marginMode: "theme"
|
||
}),
|
||
Object.freeze({
|
||
id: "standard",
|
||
label: "标准边距",
|
||
marginMode: "custom",
|
||
margins: Object.freeze({
|
||
top: "25.4mm",
|
||
right: "25.4mm",
|
||
bottom: "25.4mm",
|
||
left: "25.4mm"
|
||
})
|
||
}),
|
||
Object.freeze({
|
||
id: "compact",
|
||
label: "紧凑边距",
|
||
marginMode: "custom",
|
||
margins: Object.freeze({
|
||
top: "12.7mm",
|
||
right: "12.7mm",
|
||
bottom: "12.7mm",
|
||
left: "12.7mm"
|
||
})
|
||
}),
|
||
Object.freeze({
|
||
id: "wide",
|
||
label: "宽松边距",
|
||
marginMode: "custom",
|
||
margins: Object.freeze({
|
||
top: "31.8mm",
|
||
right: "31.8mm",
|
||
bottom: "31.8mm",
|
||
left: "31.8mm"
|
||
})
|
||
}),
|
||
Object.freeze({
|
||
id: "binding",
|
||
label: "非对称装订边距",
|
||
marginMode: "custom",
|
||
margins: Object.freeze({
|
||
top: "25.4mm",
|
||
right: "19.1mm",
|
||
bottom: "25.4mm",
|
||
left: "31.8mm"
|
||
})
|
||
})
|
||
]);
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) {
|
||
throw new Error(message);
|
||
}
|
||
}
|
||
|
||
function hasIndependentCover(manifest) {
|
||
const profiles = Array.isArray(manifest.compatibleProfiles)
|
||
? manifest.compatibleProfiles
|
||
: [];
|
||
return profiles.includes("project-report") || profiles.includes("tender");
|
||
}
|
||
|
||
export function readBundledThemeMatrixDefinitions(themesDirectory) {
|
||
assert(
|
||
typeof themesDirectory === "string" && themesDirectory.length > 0,
|
||
"主题目录不能为空"
|
||
);
|
||
return fs.readdirSync(themesDirectory, { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory())
|
||
.flatMap((entry) => {
|
||
const manifestPath = path.join(themesDirectory, entry.name, "theme.json");
|
||
if (!fs.existsSync(manifestPath)) {
|
||
return [];
|
||
}
|
||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||
if (manifest.bundled !== true) {
|
||
return [];
|
||
}
|
||
assert(
|
||
typeof manifest.id === "string" && manifest.id.length > 0,
|
||
`内置主题清单缺少 id:${manifestPath}`
|
||
);
|
||
return [{
|
||
id: manifest.id,
|
||
name:
|
||
typeof manifest.name === "string" && manifest.name.length > 0
|
||
? manifest.name
|
||
: manifest.id,
|
||
coverPageCount: hasIndependentCover(manifest) ? 1 : 0,
|
||
compatibleProfiles: Array.isArray(manifest.compatibleProfiles)
|
||
? [...manifest.compatibleProfiles]
|
||
: [],
|
||
docxFontFaces: Array.isArray(manifest.docxFonts?.faces)
|
||
? manifest.docxFonts.faces.map((face) => ({
|
||
family: face.family,
|
||
aliases: Array.isArray(face.aliases) ? [...face.aliases] : [],
|
||
weight: face.weight ?? 400,
|
||
style: face.style ?? "normal"
|
||
}))
|
||
: [],
|
||
themeDefaultMargins: manifest.pageDefaults?.margins
|
||
? structuredClone(manifest.pageDefaults.margins)
|
||
: undefined
|
||
}];
|
||
})
|
||
.sort((left, right) => left.id.localeCompare(right.id, "en"));
|
||
}
|
||
|
||
function normalizeFontName(value) {
|
||
return value
|
||
.replace(/^[A-Z]{6}\+/u, "")
|
||
.replace(/(?:[-, ](?:regular|bold|italic|bolditalic|roman|medium|light|black))+$/iu, "")
|
||
.replace(/[\s_-]+/gu, "")
|
||
.toLocaleLowerCase("en-US");
|
||
}
|
||
|
||
function isOfficeEmbeddedAlias(value, engine) {
|
||
return engine === "word"
|
||
? /^___WRD_EMBED_SUB_/iu.test(value)
|
||
: engine === "wps"
|
||
? /^WPSEMBED\d+$/iu.test(value)
|
||
: false;
|
||
}
|
||
|
||
export function getDocxFontGateFailures({
|
||
theme,
|
||
inspection,
|
||
renderedFonts,
|
||
engine
|
||
}) {
|
||
assert(theme && typeof theme.id === "string", "字体门禁缺少主题定义");
|
||
assert(inspection && typeof inspection === "object", "字体门禁缺少 DOCX 检查结果");
|
||
assert(Array.isArray(renderedFonts), "字体门禁缺少实际渲染字体");
|
||
assert(engine === "word" || engine === "wps", "字体门禁引擎必须为 word 或 wps");
|
||
const failures = [];
|
||
const declaredFaces = Array.isArray(theme.docxFontFaces)
|
||
? theme.docxFontFaces
|
||
: [];
|
||
const embeddedNames = Array.isArray(inspection.embeddedFontNames)
|
||
? inspection.embeddedFontNames
|
||
: [];
|
||
const embeddedPartCount = Number(inspection.embeddedFontPartCount ?? 0);
|
||
if (declaredFaces.length === 0) {
|
||
failures.push(`主题 ${theme.id} 未声明任何 DOCX 字体`);
|
||
}
|
||
if (embeddedPartCount === 0) {
|
||
failures.push(`主题 ${theme.id} 的 DOCX 没有嵌入字体部件`);
|
||
}
|
||
if (declaredFaces.length > 0 && embeddedPartCount !== declaredFaces.length) {
|
||
failures.push(
|
||
`主题 ${theme.id} 声明 ${declaredFaces.length} 个字体面,但 DOCX 实际嵌入 ${embeddedPartCount} 个部件`
|
||
);
|
||
}
|
||
if (embeddedPartCount > 0) {
|
||
const expected = new Set(embeddedNames.map(normalizeFontName));
|
||
const actuallyUsed = renderedFonts.some((font) => {
|
||
const name = typeof font === "string" ? font : font?.name;
|
||
return typeof name === "string" && (
|
||
isOfficeEmbeddedAlias(name, engine) || expected.has(normalizeFontName(name))
|
||
);
|
||
});
|
||
if (!actuallyUsed) {
|
||
failures.push(
|
||
`${engine === "word" ? "Microsoft Word" : "WPS Writer"} 输出未实际使用 DOCX 嵌入字体`
|
||
);
|
||
}
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
function createPaperConfig(orientation, marginScenario) {
|
||
return {
|
||
format: "A4",
|
||
orientation,
|
||
marginMode: marginScenario.marginMode,
|
||
...(marginScenario.marginMode === "custom"
|
||
? { margins: structuredClone(marginScenario.margins) }
|
||
: {})
|
||
};
|
||
}
|
||
|
||
export function createDocxLayoutVisualMatrixCases(themeDefinitions) {
|
||
assert(Array.isArray(themeDefinitions), "主题定义必须是数组");
|
||
const ids = new Set();
|
||
const cases = [];
|
||
for (const theme of themeDefinitions) {
|
||
assert(
|
||
theme && typeof theme.id === "string" && theme.id.length > 0,
|
||
"矩阵主题必须包含非空 id"
|
||
);
|
||
assert(!ids.has(theme.id), `矩阵主题 ID 重复:${theme.id}`);
|
||
ids.add(theme.id);
|
||
for (const orientation of DOCX_LAYOUT_MATRIX_ORIENTATIONS) {
|
||
for (const marginScenario of DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS) {
|
||
const paper = createPaperConfig(orientation, marginScenario);
|
||
cases.push({
|
||
id: `${theme.id}--${orientation}--${marginScenario.id}`,
|
||
themeId: theme.id,
|
||
themeName: theme.name,
|
||
orientation,
|
||
marginScenarioId: marginScenario.id,
|
||
marginScenarioLabel: marginScenario.label,
|
||
coverPageCount: theme.coverPageCount,
|
||
themeDefaultMargins: theme.themeDefaultMargins
|
||
? structuredClone(theme.themeDefaultMargins)
|
||
: undefined,
|
||
paper: { format: "A4", orientation },
|
||
config: { paper }
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return cases;
|
||
}
|
||
|
||
export function summarizeDocxLayoutVisualMatrix(themeDefinitions, cases) {
|
||
const themeIds = themeDefinitions.map((theme) => theme.id);
|
||
const caseIds = cases.map((entry) => entry.id);
|
||
const expectedCaseCount =
|
||
themeIds.length *
|
||
DOCX_LAYOUT_MATRIX_ORIENTATIONS.length *
|
||
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length;
|
||
assert(
|
||
new Set(caseIds).size === caseIds.length,
|
||
"DOCX 布局视觉矩阵存在重复场景 ID"
|
||
);
|
||
assert(
|
||
cases.length === expectedCaseCount,
|
||
`DOCX 布局视觉矩阵场景数错误:${cases.length},期望 ${expectedCaseCount}`
|
||
);
|
||
for (const themeId of themeIds) {
|
||
const themeCaseCount = cases.filter(
|
||
(entry) => entry.themeId === themeId
|
||
).length;
|
||
assert(
|
||
themeCaseCount ===
|
||
DOCX_LAYOUT_MATRIX_ORIENTATIONS.length *
|
||
DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length,
|
||
`主题 ${themeId} 的矩阵场景数错误:${themeCaseCount}`
|
||
);
|
||
}
|
||
return {
|
||
themeCount: themeIds.length,
|
||
themeIds,
|
||
orientationCount: DOCX_LAYOUT_MATRIX_ORIENTATIONS.length,
|
||
orientations: [...DOCX_LAYOUT_MATRIX_ORIENTATIONS],
|
||
marginScenarioCount: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length,
|
||
marginScenarios: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.map((scenario) => ({
|
||
id: scenario.id,
|
||
label: scenario.label,
|
||
marginMode: scenario.marginMode,
|
||
...(scenario.margins
|
||
? { margins: structuredClone(scenario.margins) }
|
||
: {})
|
||
})),
|
||
expectedCaseCount,
|
||
coverThemeCount: themeDefinitions.filter(
|
||
(theme) => theme.coverPageCount === 1
|
||
).length,
|
||
coverCaseCount: cases.filter((entry) => entry.coverPageCount === 1).length
|
||
};
|
||
}
|