feat: 完善导出设置与打印预览

This commit is contained in:
SkyJourney
2026-07-26 01:36:51 +08:00
parent ec14f7970a
commit 52cf816683
20 changed files with 1552 additions and 146 deletions
+4
View File
@@ -17,9 +17,13 @@
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"zod": "^4.1.13"
},
"devDependencies": {
"vitest": "^4.1.10"
}
}
+125 -62
View File
@@ -1,15 +1,16 @@
import { z } from "zod";
export const EXPORT_CONFIG_VERSION = 1;
export const EXPORT_CONFIG_VERSION = 2;
export const CSS_PIXELS_PER_INCH = 96;
export const PDF_POINTS_PER_INCH = 72;
export const MILLIMETERS_PER_INCH = 25.4;
export const supportedPaperFormats = [
"A3",
"A4",
"A5",
"Letter",
"Legal",
"Tabloid",
"Custom"
"Legal"
] as const;
export const paperFormatLabels: Record<
@@ -19,16 +20,52 @@ export const paperFormatLabels: Record<
A3: "A3",
A4: "A4",
A5: "A5",
Letter: "Letter",
Legal: "Legal",
Tabloid: "Tabloid",
Custom: "自定义"
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: 216, height: 279 },
Legal: { width: 216, height: 356 }
};
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;
}
const marginSchema = z.object({
top: lengthSchema,
right: lengthSchema,
@@ -36,40 +73,22 @@ const marginSchema = z.object({
left: lengthSchema
});
const headerFooterSlotSchema = z.object({
const headerSlotSchema = z.object({
enabled: z.boolean(),
content: z.string().max(500)
});
const headerFooterSchema = z.object({
const headerSchema = z.object({
enabled: z.boolean(),
height: lengthSchema,
showDivider: z.boolean(),
fontSize: lengthSchema,
color: z.string(),
left: headerFooterSlotSchema,
center: headerFooterSlotSchema,
right: headerFooterSlotSchema
left: headerSlotSchema,
center: headerSlotSchema,
right: headerSlotSchema
});
const paperSchema = z
.object({
format: z.enum(supportedPaperFormats),
orientation: z.enum(["portrait", "landscape"]),
width: lengthSchema.optional(),
height: lengthSchema.optional(),
margins: marginSchema
})
.superRefine((paper, context) => {
if (paper.format === "Custom" && (!paper.width || !paper.height)) {
context.addIssue({
code: "custom",
message: "自定义纸张必须同时指定宽度和高度",
path: ["width"]
});
}
});
export const pageNumberFormatSchema = z.enum([
"page",
"page-total",
@@ -78,21 +97,63 @@ export const pageNumberFormatSchema = z.enum([
"custom"
]);
const footerSchema = z.object({
enabled: z.boolean(),
height: lengthSchema,
showDivider: z.boolean(),
fontSize: lengthSchema,
color: z.string(),
alignment: z.enum(["left", "center", "right"]),
format: pageNumberFormatSchema,
template: z.string().max(500).optional(),
startFrom: z.number().int().positive()
});
const paperSchema = z
.object({
format: z.enum(supportedPaperFormats),
orientation: z.enum(["portrait", "landscape"]),
margins: marginSchema
})
.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 exportConfigSchema = z.object({
version: z.literal(EXPORT_CONFIG_VERSION),
name: z.string().min(1).max(100),
themeId: z.string().min(1),
paper: paperSchema,
header: headerFooterSchema,
footer: headerFooterSchema,
pageNumber: z.object({
enabled: z.boolean(),
position: z.enum(["header", "footer"]),
alignment: z.enum(["left", "center", "right"]),
format: pageNumberFormatSchema,
template: z.string().max(500).optional(),
startFrom: z.number().int().positive()
}),
header: headerSchema,
footer: footerSchema,
metadata: z.object({
title: z.string().max(300),
author: z.string().max(300),
@@ -109,25 +170,37 @@ export const exportConfigSchema = z.object({
export type ExportConfig = z.infer<typeof exportConfigSchema>;
export type PaperFormat = ExportConfig["paper"]["format"];
export type HeaderFooterConfig = ExportConfig["header"];
export type PaperOrientation = ExportConfig["paper"]["orientation"];
export type HeaderConfig = ExportConfig["header"];
export type FooterConfig = ExportConfig["footer"];
const disabledSlot = {
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-like",
name: "默认 A4 文档",
themeId: "typora-github",
paper: {
format: "A4",
orientation: "portrait",
margins: {
top: "20mm",
right: "18mm",
bottom: "20mm",
left: "18mm"
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
},
header: {
@@ -136,9 +209,9 @@ export const defaultExportConfig: ExportConfig = {
showDivider: false,
fontSize: "3mm",
color: "#6b7280",
left: disabledSlot,
center: disabledSlot,
right: disabledSlot
left: disabledHeaderSlot,
center: disabledHeaderSlot,
right: disabledHeaderSlot
},
footer: {
enabled: true,
@@ -146,16 +219,6 @@ export const defaultExportConfig: ExportConfig = {
showDivider: false,
fontSize: "3mm",
color: "#6b7280",
left: disabledSlot,
center: {
enabled: true,
content: "${page} / ${pages}"
},
right: disabledSlot
},
pageNumber: {
enabled: true,
position: "footer",
alignment: "center",
format: "page-total",
startFrom: 1
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
cssPixelsToMillimeters,
defaultExportConfig,
exportConfigSchema,
getPaperDimensionsMm,
millimetersToCssPixels,
millimetersToPdfPoints,
paperDimensionsMm,
supportedPaperFormats
} from "../src/export-config.js";
describe("导出配置", () => {
it("只保留五种固定纸张尺寸", () => {
expect(supportedPaperFormats).toEqual([
"A3",
"A4",
"A5",
"Letter",
"Legal"
]);
expect(paperDimensionsMm).toEqual({
A3: { width: 297, height: 420 },
A4: { width: 210, height: 297 },
A5: { width: 148, height: 210 },
Letter: { width: 216, height: 279 },
Legal: { width: 216, height: 356 }
});
});
it("使用 A4 和 16mm 作为默认纸张配置", () => {
expect(defaultExportConfig.paper).toEqual({
format: "A4",
orientation: "portrait",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
});
expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true);
});
it("使用统一的 96 CSS px/in 和 72 PDF pt/in 换算物理尺寸", () => {
expect(millimetersToCssPixels(25.4)).toBeCloseTo(96);
expect(cssPixelsToMillimeters(96)).toBeCloseTo(25.4);
expect(millimetersToCssPixels(210)).toBeCloseTo(793.700787);
expect(millimetersToPdfPoints(210)).toBeCloseTo(595.275591);
});
it("根据方向交换纸张宽高", () => {
expect(getPaperDimensionsMm("A5", "portrait")).toEqual({
width: 148,
height: 210
});
expect(getPaperDimensionsMm("A5", "landscape")).toEqual({
width: 210,
height: 148
});
});
it("拒绝旧配置版本和已移除的纸张类型", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
version: 1
}).success
).toBe(false);
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
paper: {
...defaultExportConfig.paper,
format: "Tabloid"
}
}).success
).toBe(false);
});
it("拒绝没有正文可用区域的页边距", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
paper: {
...defaultExportConfig.paper,
margins: {
top: "149mm",
right: "105mm",
bottom: "149mm",
left: "105mm"
}
}
}).success
).toBe(false);
});
});