Files
MorphDoc/packages/docx-theme-engine/src/css-values.ts
T
SkyJourney 64445322eb 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 均已生成并校验。
2026-08-26 10:50:20 +08:00

234 lines
5.8 KiB
TypeScript

const PX_TO_PT = 72 / 96;
const MM_TO_PT = 72 / 25.4;
function round(value: number): number {
return Math.round(value * 1000) / 1000;
}
export function parseCssLengthToPt(
input: string
): number | undefined {
const value = input.trim().toLowerCase();
if (value === "0") {
return 0;
}
const match = /^(-?(?:\d+|\d*\.\d+))(px|pt|pc|in|cm|mm|q)$/u.exec(
value
);
if (!match) {
return undefined;
}
const amount = Number(match[1]);
if (!Number.isFinite(amount)) {
return undefined;
}
const unit = match[2];
const factors: Readonly<Record<string, number>> = {
px: PX_TO_PT,
pt: 1,
pc: 12,
in: 72,
cm: 72 / 2.54,
mm: MM_TO_PT,
q: MM_TO_PT / 4
};
const factor = unit ? factors[unit] : undefined;
return factor === undefined ? undefined : round(amount * factor);
}
export function parseCssColor(
input: string
): string | undefined {
const value = input.trim().toLowerCase();
if (value === "transparent") {
return undefined;
}
const hex = /^#([\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/u.exec(value);
if (hex?.[1]) {
if (hex[1].length === 3) {
return `#${[...hex[1]].map((part) => part.repeat(2)).join("")}`;
}
if (hex[1].length === 8) {
const alpha = Number.parseInt(hex[1].slice(6), 16) / 255;
if (alpha === 0) {
return undefined;
}
const channels = [0, 2, 4].map((offset) =>
Number.parseInt(hex[1]?.slice(offset, offset + 2) ?? "0", 16)
);
return formatRgb(compositeOnWhite(channels, alpha));
}
return `#${hex[1].slice(0, 6)}`;
}
const rgb =
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*(\d+(?:\.\d+)?%?))?\s*\)$/u.exec(
value
);
if (!rgb) {
return undefined;
}
const alpha = parseAlpha(rgb[4]);
if (alpha === 0) {
return undefined;
}
const channels = rgb.slice(1, 4).map((part) =>
Math.max(0, Math.min(255, Math.round(Number(part))))
);
return formatRgb(
alpha === undefined ? channels : compositeOnWhite(channels, alpha)
);
}
export function parseCssLinearGradientFallbackColor(
input: string
): string | undefined {
const value = input.trim();
if (!/^(?:repeating-)?linear-gradient\(/iu.test(value)) {
return undefined;
}
const colorValues = value.match(
/rgba?\([^)]*\)|#[\da-f]{3,8}/giu
) ?? [];
for (const colorValue of colorValues) {
const parsed = parseCssColor(colorValue);
if (parsed) {
return parsed;
}
}
return undefined;
}
function parseAlpha(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const alpha = value.endsWith("%")
? Number(value.slice(0, -1)) / 100
: Number(value);
return Number.isFinite(alpha)
? Math.max(0, Math.min(1, alpha))
: undefined;
}
function compositeOnWhite(
channels: readonly number[],
alpha: number
): number[] {
return channels.map((channel) =>
Math.round(channel * alpha + 255 * (1 - alpha))
);
}
function formatRgb(channels: readonly number[]): string {
return `#${channels
.map((channel) => channel.toString(16).padStart(2, "0"))
.join("")}`;
}
export function cssColorHasPartialAlpha(input: string): boolean {
const value = input.trim().toLowerCase();
const hex = /^#[\da-f]{6}([\da-f]{2})$/u.exec(value);
if (hex?.[1]) {
const alpha = Number.parseInt(hex[1], 16);
return alpha > 0 && alpha < 255;
}
const rgb =
/^rgba?\([^)]*(?:[,/]\s*(\d+(?:\.\d+)?%?))\s*\)$/u.exec(value);
const alpha = parseAlpha(rgb?.[1]);
return alpha !== undefined && alpha > 0 && alpha < 1;
}
export function parseCssFontFamilies(input: string): string[] {
const families: string[] = [];
let current = "";
let quote = "";
for (const character of input) {
if ((character === `"` || character === "'") && !quote) {
quote = character;
continue;
}
if (character === quote) {
quote = "";
continue;
}
if (character === "," && !quote) {
const family = current.trim();
if (family) {
families.push(family);
}
current = "";
continue;
}
current += character;
}
const finalFamily = current.trim();
if (finalFamily) {
families.push(finalFamily);
}
return [...new Set(families)].slice(0, 20);
}
export interface ParsedCssBorder {
widthPt: number;
color: string;
style: "none" | "single" | "double" | "dotted" | "dashed";
approximated: boolean;
}
export function parseCssBorder(
input: string
): ParsedCssBorder | undefined {
const value = input.trim();
const widthMatch =
/(?:^|\s)(-?(?:\d+|\d*\.\d+)(?:px|pt|pc|in|cm|mm|q))(?=\s|$)/iu.exec(
value
);
const styleMatch =
/\b(none|hidden|solid|double|dotted|dashed|groove|ridge|inset|outset)\b/iu.exec(
value
);
const colorMatch =
/(rgba?\([^)]*\)|#[\da-f]{3,8})/iu.exec(value);
if (!widthMatch?.[1] || !styleMatch?.[1] || !colorMatch?.[1]) {
return undefined;
}
const widthPt = parseCssLengthToPt(widthMatch[1]);
const color = parseCssColor(colorMatch[1]);
if (widthPt === undefined) {
return undefined;
}
const cssStyle = styleMatch[1].toLowerCase();
if (widthPt <= 0 || cssStyle === "none" || cssStyle === "hidden") {
return {
widthPt: 0,
color: color ?? "#000000",
style: "none",
approximated: false
};
}
if (!color) {
return undefined;
}
const supported = {
solid: "single",
double: "double",
dotted: "dotted",
dashed: "dashed"
} as const;
const style = supported[cssStyle as keyof typeof supported];
return {
widthPt,
color,
style: style ?? "single",
approximated: style === undefined
};
}
export function millimetersToPoints(value: number): number {
return round(value * MM_TO_PT);
}
export function roundDocxValue(value: number): number {
return round(value);
}