建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
327 lines
8.1 KiB
TypeScript
327 lines
8.1 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const EXPORT_CONFIG_VERSION = 4;
|
|
export const CSS_PIXELS_PER_INCH = 96;
|
|
export const PDF_POINTS_PER_INCH = 72;
|
|
export const MILLIMETERS_PER_INCH = 25.4;
|
|
|
|
export const supportedMermaidLayouts = ["dagre", "elk"] as const;
|
|
export const supportedMermaidThemes = [
|
|
"default",
|
|
"base",
|
|
"dark",
|
|
"forest",
|
|
"neutral",
|
|
"neo",
|
|
"neo-dark",
|
|
"redux",
|
|
"redux-dark",
|
|
"redux-color",
|
|
"redux-dark-color"
|
|
] as const;
|
|
export const supportedMermaidLooks = [
|
|
"classic",
|
|
"handDrawn",
|
|
"neo"
|
|
] as const;
|
|
|
|
export const supportedPaperFormats = [
|
|
"A3",
|
|
"A4",
|
|
"A5",
|
|
"Letter",
|
|
"Legal"
|
|
] as const;
|
|
|
|
export const paperFormatLabels: Record<
|
|
(typeof supportedPaperFormats)[number],
|
|
string
|
|
> = {
|
|
A3: "A3",
|
|
A4: "A4",
|
|
A5: "A5",
|
|
Letter: "US-Letter",
|
|
Legal: "US-Legal"
|
|
};
|
|
|
|
export const paperDimensionsMm: Record<
|
|
(typeof supportedPaperFormats)[number],
|
|
{ width: number; height: number }
|
|
> = {
|
|
A3: { width: 297, height: 420 },
|
|
A4: { width: 210, height: 297 },
|
|
A5: { width: 148, height: 210 },
|
|
Letter: { width: 215.9, height: 279.4 },
|
|
Legal: { width: 215.9, height: 355.6 }
|
|
};
|
|
|
|
const lengthSchema = z
|
|
.string()
|
|
.regex(/^\d+(?:\.\d+)?(?:mm|cm|in)$/, "长度必须包含 mm、cm 或 in 单位");
|
|
|
|
export function lengthToMillimeters(value: string) {
|
|
const number = Number.parseFloat(value);
|
|
if (value.endsWith("cm")) {
|
|
return number * 10;
|
|
}
|
|
if (value.endsWith("in")) {
|
|
return number * MILLIMETERS_PER_INCH;
|
|
}
|
|
return number;
|
|
}
|
|
|
|
export function millimetersToCssPixels(value: number) {
|
|
return (value * CSS_PIXELS_PER_INCH) / MILLIMETERS_PER_INCH;
|
|
}
|
|
|
|
export function cssPixelsToMillimeters(value: number) {
|
|
return (value * MILLIMETERS_PER_INCH) / CSS_PIXELS_PER_INCH;
|
|
}
|
|
|
|
export function cssPixelsToPdfPoints(value: number) {
|
|
return (value * PDF_POINTS_PER_INCH) / CSS_PIXELS_PER_INCH;
|
|
}
|
|
|
|
export function millimetersToPdfPoints(value: number) {
|
|
return (value * PDF_POINTS_PER_INCH) / MILLIMETERS_PER_INCH;
|
|
}
|
|
|
|
export const pageMarginsSchema = z.object({
|
|
top: lengthSchema,
|
|
right: lengthSchema,
|
|
bottom: lengthSchema,
|
|
left: lengthSchema
|
|
});
|
|
|
|
export const marginModeSchema = z.enum(["theme", "custom"]);
|
|
export const pageDecorationsModeSchema = z.enum(["theme", "custom"]);
|
|
|
|
export const headerSlotSchema = z.object({
|
|
enabled: z.boolean(),
|
|
content: z.string().max(500)
|
|
});
|
|
|
|
const pageDecorationFontFamilySchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(300)
|
|
.regex(
|
|
/^[\p{L}\p{N}\s,"'-]+$/u,
|
|
"页眉页码字体只能包含字体名称、引号和逗号"
|
|
);
|
|
|
|
export const headerSchema = z.object({
|
|
enabled: z.boolean(),
|
|
height: lengthSchema,
|
|
showOnFirstPage: z.boolean().default(true),
|
|
showDivider: z.boolean(),
|
|
fontSize: lengthSchema,
|
|
fontFamily: pageDecorationFontFamilySchema.default(
|
|
'"Segoe UI", "Microsoft YaHei", sans-serif'
|
|
),
|
|
color: z.string(),
|
|
left: headerSlotSchema,
|
|
center: headerSlotSchema,
|
|
right: headerSlotSchema
|
|
});
|
|
|
|
export const pageNumberFormatSchema = z.enum([
|
|
"page",
|
|
"page-total",
|
|
"chinese-page-total",
|
|
"dash-page",
|
|
"official-page",
|
|
"custom"
|
|
]);
|
|
|
|
export const footerAlignmentSchema = z.enum([
|
|
"left",
|
|
"center",
|
|
"right",
|
|
"outer"
|
|
]);
|
|
|
|
export const footerSchema = z.object({
|
|
enabled: z.boolean(),
|
|
height: lengthSchema,
|
|
showDivider: z.boolean(),
|
|
fontSize: lengthSchema,
|
|
fontFamily: pageDecorationFontFamilySchema.default(
|
|
'"Segoe UI", "Microsoft YaHei", sans-serif'
|
|
),
|
|
color: z.string(),
|
|
alignment: footerAlignmentSchema,
|
|
format: pageNumberFormatSchema,
|
|
template: z.string().max(500).optional(),
|
|
startFrom: z.number().int().positive(),
|
|
showOnFirstPage: z.boolean().default(true)
|
|
});
|
|
|
|
const paperSchema = z
|
|
.object({
|
|
format: z.enum(supportedPaperFormats),
|
|
orientation: z.enum(["portrait", "landscape"]),
|
|
marginMode: marginModeSchema,
|
|
margins: pageMarginsSchema
|
|
})
|
|
.superRefine((paper, context) => {
|
|
const sourceDimensions = paperDimensionsMm[paper.format];
|
|
const dimensions =
|
|
paper.orientation === "portrait"
|
|
? sourceDimensions
|
|
: {
|
|
width: sourceDimensions.height,
|
|
height: sourceDimensions.width
|
|
};
|
|
const horizontalMargins =
|
|
lengthToMillimeters(paper.margins.left) +
|
|
lengthToMillimeters(paper.margins.right);
|
|
const verticalMargins =
|
|
lengthToMillimeters(paper.margins.top) +
|
|
lengthToMillimeters(paper.margins.bottom);
|
|
|
|
if (horizontalMargins >= dimensions.width) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "左右页边距之和必须小于纸张宽度",
|
|
path: ["margins", "right"]
|
|
});
|
|
}
|
|
if (verticalMargins >= dimensions.height) {
|
|
context.addIssue({
|
|
code: "custom",
|
|
message: "上下页边距之和必须小于纸张高度",
|
|
path: ["margins", "bottom"]
|
|
});
|
|
}
|
|
});
|
|
|
|
export const mermaidConfigSchema = z.object({
|
|
layout: z.enum(supportedMermaidLayouts),
|
|
theme: z.enum(supportedMermaidThemes),
|
|
look: z.enum(supportedMermaidLooks),
|
|
fontFamily: z.string().trim().min(1).max(300)
|
|
});
|
|
|
|
export const exportConfigSchema = z.object({
|
|
version: z.literal(EXPORT_CONFIG_VERSION),
|
|
name: z.string().min(1).max(100),
|
|
themeId: z.string().min(1),
|
|
pageDecorationsMode: pageDecorationsModeSchema.default("theme"),
|
|
mermaid: mermaidConfigSchema,
|
|
paper: paperSchema,
|
|
header: headerSchema,
|
|
footer: footerSchema,
|
|
metadata: z.object({
|
|
title: z.string().max(300),
|
|
author: z.string().max(300),
|
|
subject: z.string().max(500),
|
|
keywords: z.array(z.string().max(100)).max(30),
|
|
language: z.string().max(30)
|
|
}),
|
|
print: z.object({
|
|
background: z.boolean(),
|
|
scale: z.number().min(0.1).max(2),
|
|
pageBreakBeforeH1: z.boolean()
|
|
})
|
|
});
|
|
|
|
export type ExportConfig = z.infer<typeof exportConfigSchema>;
|
|
export type MermaidExportConfig = ExportConfig["mermaid"];
|
|
export type PaperFormat = ExportConfig["paper"]["format"];
|
|
export type PaperOrientation = ExportConfig["paper"]["orientation"];
|
|
export type PageMargins = ExportConfig["paper"]["margins"];
|
|
export type MarginMode = ExportConfig["paper"]["marginMode"];
|
|
export type PageDecorationsMode = ExportConfig["pageDecorationsMode"];
|
|
export type HeaderConfig = ExportConfig["header"];
|
|
export type FooterConfig = ExportConfig["footer"];
|
|
|
|
export const defaultPageMargins: PageMargins = {
|
|
top: "16mm",
|
|
right: "16mm",
|
|
bottom: "16mm",
|
|
left: "16mm"
|
|
};
|
|
|
|
export function resolvePageMargins(
|
|
paper: Pick<ExportConfig["paper"], "marginMode" | "margins">,
|
|
themeMargins?: PageMargins
|
|
): PageMargins {
|
|
const source =
|
|
paper.marginMode === "theme"
|
|
? themeMargins ?? defaultPageMargins
|
|
: paper.margins;
|
|
return { ...source };
|
|
}
|
|
|
|
export function getPaperDimensionsMm(
|
|
format: PaperFormat,
|
|
orientation: PaperOrientation
|
|
) {
|
|
const dimensions = paperDimensionsMm[format];
|
|
return orientation === "portrait"
|
|
? dimensions
|
|
: { width: dimensions.height, height: dimensions.width };
|
|
}
|
|
|
|
const disabledHeaderSlot = {
|
|
enabled: false,
|
|
content: ""
|
|
} as const;
|
|
|
|
export const defaultExportConfig: ExportConfig = {
|
|
version: EXPORT_CONFIG_VERSION,
|
|
name: "默认 A4 文档",
|
|
themeId: "typora-github",
|
|
pageDecorationsMode: "theme",
|
|
mermaid: {
|
|
layout: "dagre",
|
|
theme: "default",
|
|
look: "classic",
|
|
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
|
|
},
|
|
paper: {
|
|
format: "A4",
|
|
orientation: "portrait",
|
|
marginMode: "theme",
|
|
margins: { ...defaultPageMargins }
|
|
},
|
|
header: {
|
|
enabled: false,
|
|
height: "8mm",
|
|
showOnFirstPage: true,
|
|
showDivider: false,
|
|
fontSize: "3mm",
|
|
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
|
color: "#6b7280",
|
|
left: disabledHeaderSlot,
|
|
center: disabledHeaderSlot,
|
|
right: disabledHeaderSlot
|
|
},
|
|
footer: {
|
|
enabled: true,
|
|
height: "8mm",
|
|
showDivider: false,
|
|
fontSize: "3mm",
|
|
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
|
|
color: "#6b7280",
|
|
alignment: "center",
|
|
format: "page-total",
|
|
startFrom: 1,
|
|
showOnFirstPage: true
|
|
},
|
|
metadata: {
|
|
title: "",
|
|
author: "",
|
|
subject: "",
|
|
keywords: [],
|
|
language: "zh-CN"
|
|
},
|
|
print: {
|
|
background: true,
|
|
scale: 1,
|
|
pageBreakBeforeH1: false
|
|
}
|
|
};
|