feat: 实现 DOCX 主题样式采集
This commit is contained in:
@@ -9,6 +9,8 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
|
||||
- 覆盖普通文档、公文、简报、项目报告和标书的标准探针 DOM;
|
||||
- Chromium/Electron 计算样式快照协议;
|
||||
- 不绑定具体浏览器实现的计算样式采集器;
|
||||
- 可安全注入主题 CSS、等待字体并自动清理 iframe 的浏览器运行脚本;
|
||||
- 基于主题 CSS SHA-256 指纹的并发合并和 LRU 快照缓存;
|
||||
- Word 目标样式令牌;
|
||||
- 自动映射、显式覆盖和预设降级配置;
|
||||
- 样式来源、映射置信度和诊断协议;
|
||||
@@ -22,7 +24,7 @@ DOCX 主题映射引擎负责在浏览器主题 CSS 与 Word 样式之间建立
|
||||
- 直接修改 OOXML;
|
||||
- 按主题 ID 维护专属转换分支。
|
||||
|
||||
后续 Chromium 与 Electron 适配器只负责挂载探针、应用主题 CSS,并将
|
||||
平台的 `getComputedStyle()` 接入本包采集器。本包负责将快照归一化为
|
||||
DOCX 样式令牌,`@md-to-pdf/docx-engine` 再消费令牌生成
|
||||
`reference.docx` 和最终 OOXML。
|
||||
Server Playwright 与 Desktop Electron 适配器负责在现有受限浏览器生命周期
|
||||
中执行本包脚本;主题 CSS 和字体资源继续使用各平台已有的同源资源协议。
|
||||
本包下一步负责将快照归一化为 DOCX 样式令牌,
|
||||
`@md-to-pdf/docx-engine` 再消费令牌生成 `reference.docx` 和最终 OOXML。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export async function createDocxThemeStyleFingerprint(
|
||||
themeId: string,
|
||||
themeCss: string
|
||||
): Promise<string> {
|
||||
const content = new TextEncoder().encode(
|
||||
`docx-theme-style-v1\0${themeId}\0${themeCss}`
|
||||
);
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
content
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
export * from "./collector.js";
|
||||
export * from "./configuration.js";
|
||||
export * from "./fingerprint.js";
|
||||
export * from "./probe.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./snapshot-cache.js";
|
||||
export * from "./slots.js";
|
||||
export * from "./snapshot.js";
|
||||
export * from "./tokens.js";
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { z } from "zod";
|
||||
import { createDocxStyleProbeMarkup } from "./probe.js";
|
||||
import {
|
||||
docxThemeStyleSnapshotSchema,
|
||||
type DocxComputedStyle,
|
||||
type DocxThemeStyleSnapshot
|
||||
} from "./snapshot.js";
|
||||
import { DOCX_STYLE_SLOTS } from "./slots.js";
|
||||
|
||||
const DOCX_STYLE_PROBE_WIDTH_PX = 794;
|
||||
const DOCX_STYLE_PROBE_HEIGHT_PX = 1123;
|
||||
const DOCX_STYLE_PROBE_CONTENT_WIDTH_PX = 640;
|
||||
|
||||
export const docxThemeStyleCaptureRequestSchema = z.object({
|
||||
themeId: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
||||
themeFingerprint: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
themeCss: z.string().max(4 * 1024 * 1024),
|
||||
baseUrl: z.string().url().max(2048)
|
||||
});
|
||||
|
||||
export type DocxThemeStyleCaptureRequest = z.infer<
|
||||
typeof docxThemeStyleCaptureRequestSchema
|
||||
>;
|
||||
|
||||
export interface DocxThemeStyleCaptureAdapter {
|
||||
captureThemeStyle(
|
||||
request: DocxThemeStyleCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<DocxThemeStyleSnapshot>;
|
||||
}
|
||||
|
||||
const runtimeCaptureSchema = z.object({
|
||||
rootFontSizePx: z.number().positive().max(200),
|
||||
viewport: z.object({
|
||||
widthPx: z.number().int().positive().max(10000),
|
||||
heightPx: z.number().int().positive().max(10000),
|
||||
deviceScaleFactor: z.number().positive().max(10)
|
||||
}),
|
||||
slots: z.array(
|
||||
z.object({
|
||||
slot: z.string(),
|
||||
matched: z.boolean(),
|
||||
computed: z.record(z.string(), z.string()).optional()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
const computedProperties = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"color",
|
||||
"backgroundColor",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textAlign",
|
||||
"textIndent",
|
||||
"textDecorationLine",
|
||||
"marginTop",
|
||||
"marginRight",
|
||||
"marginBottom",
|
||||
"marginLeft",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"borderTop",
|
||||
"borderRight",
|
||||
"borderBottom",
|
||||
"borderLeft",
|
||||
"width",
|
||||
"maxWidth",
|
||||
"breakBefore",
|
||||
"breakAfter",
|
||||
"breakInside",
|
||||
"display"
|
||||
] as const satisfies readonly (keyof DocxComputedStyle)[];
|
||||
|
||||
const baselineCss = `
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#write {
|
||||
width: ${DOCX_STYLE_PROBE_CONTENT_WIDTH_PX}px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export function createDocxThemeStyleCaptureScript(
|
||||
input: DocxThemeStyleCaptureRequest
|
||||
): string {
|
||||
const request = docxThemeStyleCaptureRequestSchema.parse(input);
|
||||
const payload = JSON.stringify({
|
||||
request,
|
||||
markup: createDocxStyleProbeMarkup(),
|
||||
slots: DOCX_STYLE_SLOTS,
|
||||
properties: computedProperties,
|
||||
baselineCss,
|
||||
viewport: {
|
||||
width: DOCX_STYLE_PROBE_WIDTH_PX,
|
||||
height: DOCX_STYLE_PROBE_HEIGHT_PX
|
||||
}
|
||||
})
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("\u2028", "\\u2028")
|
||||
.replaceAll("\u2029", "\\u2029");
|
||||
return `(async () => {
|
||||
const payload = ${payload};
|
||||
const frame = document.createElement("iframe");
|
||||
frame.setAttribute("aria-hidden", "true");
|
||||
frame.style.cssText =
|
||||
"position:fixed;left:-32000px;top:-32000px;" +
|
||||
"width:" + payload.viewport.width + "px;" +
|
||||
"height:" + payload.viewport.height + "px;" +
|
||||
"border:0;visibility:hidden;pointer-events:none;";
|
||||
document.body.appendChild(frame);
|
||||
try {
|
||||
const probeDocument = frame.contentDocument;
|
||||
const probeWindow = frame.contentWindow;
|
||||
if (!probeDocument || !probeWindow) {
|
||||
throw new Error("DOCX 主题样式探针 iframe 不可用");
|
||||
}
|
||||
probeDocument.open();
|
||||
probeDocument.write(
|
||||
"<!doctype html><html><head></head><body></body></html>"
|
||||
);
|
||||
probeDocument.close();
|
||||
const base = probeDocument.createElement("base");
|
||||
base.href = payload.request.baseUrl;
|
||||
probeDocument.head.appendChild(base);
|
||||
const baseline = probeDocument.createElement("style");
|
||||
baseline.dataset.docxProbeStyle = "baseline";
|
||||
baseline.textContent = payload.baselineCss;
|
||||
probeDocument.head.appendChild(baseline);
|
||||
const theme = probeDocument.createElement("style");
|
||||
theme.dataset.docxProbeStyle = "theme";
|
||||
theme.textContent = payload.request.themeCss;
|
||||
probeDocument.head.appendChild(theme);
|
||||
probeDocument.body.innerHTML = payload.markup;
|
||||
const fontReady = probeDocument.fonts
|
||||
? probeDocument.fonts.ready
|
||||
: Promise.resolve();
|
||||
await Promise.race([
|
||||
fontReady,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error("DOCX 主题字体等待超时")),
|
||||
10000
|
||||
);
|
||||
})
|
||||
]);
|
||||
await new Promise((resolve) =>
|
||||
probeWindow.requestAnimationFrame(() =>
|
||||
probeWindow.requestAnimationFrame(resolve)
|
||||
)
|
||||
);
|
||||
const slots = payload.slots.map((definition) => {
|
||||
const element = probeDocument.querySelector(
|
||||
definition.selector
|
||||
);
|
||||
if (!element) {
|
||||
return {
|
||||
slot: definition.name,
|
||||
matched: false
|
||||
};
|
||||
}
|
||||
const style = probeWindow.getComputedStyle(element);
|
||||
const computed = {};
|
||||
for (const property of payload.properties) {
|
||||
computed[property] = style[property];
|
||||
}
|
||||
return {
|
||||
slot: definition.name,
|
||||
matched: true,
|
||||
computed
|
||||
};
|
||||
});
|
||||
const rootFontSize = Number.parseFloat(
|
||||
probeWindow.getComputedStyle(
|
||||
probeDocument.documentElement
|
||||
).fontSize
|
||||
);
|
||||
return {
|
||||
rootFontSizePx: rootFontSize,
|
||||
viewport: {
|
||||
widthPx: payload.viewport.width,
|
||||
heightPx: payload.viewport.height,
|
||||
deviceScaleFactor: probeWindow.devicePixelRatio || 1
|
||||
},
|
||||
slots
|
||||
};
|
||||
} finally {
|
||||
frame.remove();
|
||||
}
|
||||
})()`;
|
||||
}
|
||||
|
||||
export function parseDocxThemeStyleRuntimeCapture(
|
||||
requestInput: DocxThemeStyleCaptureRequest,
|
||||
captureInput: unknown
|
||||
): DocxThemeStyleSnapshot {
|
||||
const request =
|
||||
docxThemeStyleCaptureRequestSchema.parse(requestInput);
|
||||
const capture = runtimeCaptureSchema.parse(captureInput);
|
||||
return docxThemeStyleSnapshotSchema.parse({
|
||||
schemaVersion: 1,
|
||||
themeId: request.themeId,
|
||||
themeFingerprint: request.themeFingerprint,
|
||||
viewport: capture.viewport,
|
||||
rootFontSizePx: capture.rootFontSizePx,
|
||||
slots: capture.slots
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { DocxThemeStyleSnapshot } from "./snapshot.js";
|
||||
import {
|
||||
docxThemeStyleCaptureRequestSchema,
|
||||
type DocxThemeStyleCaptureAdapter,
|
||||
type DocxThemeStyleCaptureRequest
|
||||
} from "./runtime.js";
|
||||
|
||||
export interface DocxThemeStyleSnapshotCacheOptions {
|
||||
maximumEntries?: number;
|
||||
}
|
||||
|
||||
function waitWithSignal<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
void promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class DocxThemeStyleSnapshotCache {
|
||||
private readonly maximumEntries: number;
|
||||
private readonly entries =
|
||||
new Map<string, DocxThemeStyleSnapshot>();
|
||||
private readonly pending =
|
||||
new Map<string, Promise<DocxThemeStyleSnapshot>>();
|
||||
|
||||
constructor(
|
||||
private readonly adapter: DocxThemeStyleCaptureAdapter,
|
||||
options: DocxThemeStyleSnapshotCacheOptions = {}
|
||||
) {
|
||||
this.maximumEntries = options.maximumEntries ?? 32;
|
||||
if (
|
||||
!Number.isInteger(this.maximumEntries) ||
|
||||
this.maximumEntries < 1 ||
|
||||
this.maximumEntries > 1000
|
||||
) {
|
||||
throw new Error("DOCX 主题样式缓存容量必须是 1 到 1000 的整数");
|
||||
}
|
||||
}
|
||||
|
||||
capture(
|
||||
input: DocxThemeStyleCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<DocxThemeStyleSnapshot> {
|
||||
const request =
|
||||
docxThemeStyleCaptureRequestSchema.parse(input);
|
||||
const key = this.createKey(request);
|
||||
const cached = this.entries.get(key);
|
||||
if (cached) {
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, cached);
|
||||
return waitWithSignal(Promise.resolve(cached), signal);
|
||||
}
|
||||
let operation = this.pending.get(key);
|
||||
if (!operation) {
|
||||
operation = this.adapter
|
||||
.captureThemeStyle(request)
|
||||
.then((snapshot) => {
|
||||
this.entries.set(key, snapshot);
|
||||
this.trim();
|
||||
return snapshot;
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending.delete(key);
|
||||
});
|
||||
this.pending.set(key, operation);
|
||||
}
|
||||
return waitWithSignal(operation, signal);
|
||||
}
|
||||
|
||||
invalidate(themeId?: string) {
|
||||
if (!themeId) {
|
||||
this.entries.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, snapshot] of this.entries) {
|
||||
if (snapshot.themeId === themeId) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
private createKey(request: DocxThemeStyleCaptureRequest) {
|
||||
return `${request.themeId}:${request.themeFingerprint}`;
|
||||
}
|
||||
|
||||
private trim() {
|
||||
while (this.entries.size > this.maximumEntries) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
return;
|
||||
}
|
||||
this.entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DOCX_STYLE_SLOT_NAMES,
|
||||
DocxThemeStyleSnapshotCache,
|
||||
createDocxThemeStyleCaptureScript,
|
||||
createDocxThemeStyleFingerprint,
|
||||
parseDocxThemeStyleRuntimeCapture,
|
||||
type DocxComputedStyle,
|
||||
type DocxThemeStyleCaptureRequest
|
||||
} from "../src/index.js";
|
||||
|
||||
const request: DocxThemeStyleCaptureRequest = {
|
||||
themeId: "external-clean",
|
||||
themeFingerprint: "d".repeat(64),
|
||||
themeCss: "#write { color: #111; }",
|
||||
baseUrl: "https://example.invalid/"
|
||||
};
|
||||
|
||||
const computed: DocxComputedStyle = {
|
||||
fontFamily: "Arial",
|
||||
fontSize: "16px",
|
||||
fontWeight: "400",
|
||||
fontStyle: "normal",
|
||||
color: "rgb(17, 17, 17)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
lineHeight: "normal",
|
||||
letterSpacing: "normal",
|
||||
textAlign: "start",
|
||||
textIndent: "0px",
|
||||
textDecorationLine: "none",
|
||||
marginTop: "0px",
|
||||
marginRight: "0px",
|
||||
marginBottom: "0px",
|
||||
marginLeft: "0px",
|
||||
paddingTop: "0px",
|
||||
paddingRight: "0px",
|
||||
paddingBottom: "0px",
|
||||
paddingLeft: "0px",
|
||||
borderTop: "0px none rgb(17, 17, 17)",
|
||||
borderRight: "0px none rgb(17, 17, 17)",
|
||||
borderBottom: "0px none rgb(17, 17, 17)",
|
||||
borderLeft: "0px none rgb(17, 17, 17)",
|
||||
width: "640px",
|
||||
maxWidth: "none",
|
||||
breakBefore: "auto",
|
||||
breakAfter: "auto",
|
||||
breakInside: "auto",
|
||||
display: "block"
|
||||
};
|
||||
|
||||
function createRuntimeCapture() {
|
||||
return {
|
||||
rootFontSizePx: 16,
|
||||
viewport: {
|
||||
widthPx: 794,
|
||||
heightPx: 1123,
|
||||
deviceScaleFactor: 1
|
||||
},
|
||||
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
|
||||
slot,
|
||||
matched: true,
|
||||
computed
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
describe("DOCX 主题样式浏览器运行时", () => {
|
||||
it("脚本只通过序列化载荷注入主题 CSS", () => {
|
||||
const script = createDocxThemeStyleCaptureScript({
|
||||
...request,
|
||||
themeCss: '#write::before { content: "</style><script>x</script>"; }'
|
||||
});
|
||||
expect(script).toContain("theme.textContent");
|
||||
expect(script).toContain("frame.remove()");
|
||||
expect(script).not.toContain("</style><script>x</script>");
|
||||
});
|
||||
|
||||
it("将浏览器采集结果绑定到主题身份", () => {
|
||||
const snapshot = parseDocxThemeStyleRuntimeCapture(
|
||||
request,
|
||||
createRuntimeCapture()
|
||||
);
|
||||
expect(snapshot.themeId).toBe("external-clean");
|
||||
expect(snapshot.slots).toHaveLength(
|
||||
DOCX_STYLE_SLOT_NAMES.length
|
||||
);
|
||||
});
|
||||
|
||||
it("同一主题内容生成稳定 SHA-256 指纹", async () => {
|
||||
const first = await createDocxThemeStyleFingerprint(
|
||||
request.themeId,
|
||||
request.themeCss
|
||||
);
|
||||
const second = await createDocxThemeStyleFingerprint(
|
||||
request.themeId,
|
||||
request.themeCss
|
||||
);
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(
|
||||
await createDocxThemeStyleFingerprint(
|
||||
request.themeId,
|
||||
`${request.themeCss}\n`
|
||||
)
|
||||
).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOCX 主题样式快照缓存", () => {
|
||||
it("合并并发采集并按指纹复用结果", async () => {
|
||||
const adapter = {
|
||||
captureThemeStyle: vi.fn(async (input) =>
|
||||
parseDocxThemeStyleRuntimeCapture(
|
||||
input,
|
||||
createRuntimeCapture()
|
||||
)
|
||||
)
|
||||
};
|
||||
const cache = new DocxThemeStyleSnapshotCache(adapter);
|
||||
const [first, second] = await Promise.all([
|
||||
cache.capture(request),
|
||||
cache.capture(request)
|
||||
]);
|
||||
expect(first).toBe(second);
|
||||
expect(adapter.captureThemeStyle).toHaveBeenCalledTimes(1);
|
||||
expect((await cache.capture(request))).toBe(first);
|
||||
expect(adapter.captureThemeStyle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("按最近使用顺序限制缓存容量", async () => {
|
||||
const adapter = {
|
||||
captureThemeStyle: vi.fn(async (input) =>
|
||||
parseDocxThemeStyleRuntimeCapture(
|
||||
input,
|
||||
createRuntimeCapture()
|
||||
)
|
||||
)
|
||||
};
|
||||
const cache = new DocxThemeStyleSnapshotCache(adapter, {
|
||||
maximumEntries: 1
|
||||
});
|
||||
await cache.capture(request);
|
||||
await cache.capture({
|
||||
...request,
|
||||
themeId: "external-dark",
|
||||
themeFingerprint: "e".repeat(64)
|
||||
});
|
||||
expect(cache.size).toBe(1);
|
||||
await cache.capture(request);
|
||||
expect(adapter.captureThemeStyle).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user