feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
copyFile,
|
||||
mkdtemp,
|
||||
rm,
|
||||
writeFile
|
||||
} from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defaultExportConfig,
|
||||
type PreparedDocxMedia,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PandocDocxConversionError,
|
||||
PandocDocxConverter,
|
||||
type PandocProcessRunner,
|
||||
type PandocRuntimeProvider
|
||||
} from "../src/index.js";
|
||||
import { createTestBaselineReference } from "./reference-test-fixture.js";
|
||||
|
||||
function theme(): ThemeManifest {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "test-theme",
|
||||
name: "测试主题",
|
||||
version: "1.0.0",
|
||||
description: "测试",
|
||||
author: "测试",
|
||||
license: "内部许可",
|
||||
entry: "theme.css",
|
||||
domPreset: "generic",
|
||||
defaultFontSize: "16px",
|
||||
supportedFeatures: [],
|
||||
category: "general",
|
||||
compatibleProfiles: [],
|
||||
docxStyle: { preset: "technical" },
|
||||
bundled: true
|
||||
};
|
||||
}
|
||||
|
||||
const emptyMedia: PreparedDocxMedia = {
|
||||
resources: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
warnings: [],
|
||||
totalBytes: 0
|
||||
};
|
||||
|
||||
function input() {
|
||||
return {
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig,
|
||||
theme: theme(),
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "测试人",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
media: emptyMedia
|
||||
};
|
||||
}
|
||||
|
||||
function argumentAfter(arguments_: readonly string[], name: string) {
|
||||
const index = arguments_.indexOf(name);
|
||||
if (index < 0 || !arguments_[index + 1]) {
|
||||
throw new Error(`缺少参数 ${name}`);
|
||||
}
|
||||
return arguments_[index + 1]!;
|
||||
}
|
||||
|
||||
describe("Pandoc DOCX 转换器", () => {
|
||||
let temporaryRoot: string;
|
||||
const baseline = createTestBaselineReference();
|
||||
const runtime: PandocRuntimeProvider = {
|
||||
async getExecutablePath() {
|
||||
return "pandoc";
|
||||
},
|
||||
async getDefaultReferenceDocx() {
|
||||
return baseline;
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
temporaryRoot = await mkdtemp(
|
||||
path.join(os.tmpdir(), "docx-converter-test-")
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("使用固定参数转换、校验并清理请求级临时目录", async () => {
|
||||
let requestDirectory = "";
|
||||
const runner = vi.fn<PandocProcessRunner>(
|
||||
async (_executable, arguments_, options) => {
|
||||
requestDirectory = options.cwd!;
|
||||
expect(arguments_).toContain("--lua-filter");
|
||||
expect(arguments_).toContain("--data-dir");
|
||||
expect(arguments_).toContain("--resource-path");
|
||||
expect(
|
||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||
).toContain("media-map.json");
|
||||
await copyFile(
|
||||
argumentAfter(arguments_, "--reference-doc"),
|
||||
argumentAfter(arguments_, "--output")
|
||||
);
|
||||
return {
|
||||
outcome: "completed",
|
||||
exitCode: 0,
|
||||
stdout: new Uint8Array(),
|
||||
stderr: ""
|
||||
};
|
||||
}
|
||||
);
|
||||
const converter = new PandocDocxConverter({
|
||||
runtime,
|
||||
runner,
|
||||
temporaryRoot
|
||||
});
|
||||
const result = await converter.convert(input());
|
||||
|
||||
expect(result.docx.byteLength).toBeGreaterThan(0);
|
||||
expect(result.validation.partCount).toBeGreaterThan(8);
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
expect(existsSync(requestDirectory)).toBe(false);
|
||||
});
|
||||
|
||||
it("超时和无效输出均返回稳定错误并清理目录", async () => {
|
||||
let timeoutDirectory = "";
|
||||
const timeoutRunner = vi.fn<PandocProcessRunner>(
|
||||
async (_executable, _arguments, options) => {
|
||||
timeoutDirectory = options.cwd!;
|
||||
return {
|
||||
outcome: "timeout",
|
||||
exitCode: null,
|
||||
stdout: new Uint8Array(),
|
||||
stderr: ""
|
||||
};
|
||||
}
|
||||
);
|
||||
await expect(
|
||||
new PandocDocxConverter({
|
||||
runtime,
|
||||
runner: timeoutRunner,
|
||||
temporaryRoot
|
||||
}).convert(input())
|
||||
).rejects.toMatchObject({
|
||||
code: "DOCX_RENDER_TIMEOUT"
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
expect(existsSync(timeoutDirectory)).toBe(false);
|
||||
|
||||
const invalidRunner = vi.fn<PandocProcessRunner>(
|
||||
async (_executable, arguments_) => {
|
||||
await writeFile(
|
||||
argumentAfter(arguments_, "--output"),
|
||||
"not a docx"
|
||||
);
|
||||
return {
|
||||
outcome: "completed",
|
||||
exitCode: 0,
|
||||
stdout: new Uint8Array(),
|
||||
stderr: ""
|
||||
};
|
||||
}
|
||||
);
|
||||
await expect(
|
||||
new PandocDocxConverter({
|
||||
runtime,
|
||||
runner: invalidRunner,
|
||||
temporaryRoot
|
||||
}).convert(input())
|
||||
).rejects.toMatchObject({
|
||||
code: "DOCX_OUTPUT_INVALID"
|
||||
} satisfies Partial<PandocDocxConversionError>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DocxMediaKind,
|
||||
DocxPngMediaResource,
|
||||
PreparedDocxMedia
|
||||
} from "@md-to-pdf/core";
|
||||
import { preparePandocMedia } from "../src/index.js";
|
||||
|
||||
const png = Uint8Array.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01
|
||||
]);
|
||||
|
||||
function resource(
|
||||
kind: DocxMediaKind,
|
||||
ordinal: number,
|
||||
kindOrdinal: number
|
||||
): DocxPngMediaResource {
|
||||
return {
|
||||
id: `docx-media-${ordinal}`,
|
||||
kind,
|
||||
ordinal,
|
||||
kindOrdinal,
|
||||
altText: `${kind} ${kindOrdinal}`,
|
||||
caption: `${kind} 图注`,
|
||||
displayWidthPx: 384,
|
||||
displayHeightPx: 192,
|
||||
captureX: 0,
|
||||
captureY: 0,
|
||||
captureWidthPx: 384,
|
||||
captureHeightPx: 192,
|
||||
rasterScale: 1,
|
||||
fileName: `media-${ordinal}.png`,
|
||||
contentType: "image/png",
|
||||
content: png,
|
||||
pixelWidth: 1,
|
||||
pixelHeight: 1
|
||||
};
|
||||
}
|
||||
|
||||
function prepared(
|
||||
resources: DocxPngMediaResource[]
|
||||
): PreparedDocxMedia {
|
||||
return {
|
||||
resources,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
warnings: [],
|
||||
totalBytes: resources.reduce(
|
||||
(total, item) => total + item.content.byteLength,
|
||||
0
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
describe("Pandoc DOCX 媒体映射", () => {
|
||||
it("按媒体类型和捕获顺序生成受控路径与物理尺寸", () => {
|
||||
const result = preparePandocMedia(
|
||||
prepared([
|
||||
resource("image", 1, 1),
|
||||
resource("mermaid", 2, 1),
|
||||
resource("echarts", 3, 1)
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files.map((item) => item.relativePath)).toEqual([
|
||||
"media/media-001.png",
|
||||
"media/media-002.png",
|
||||
"media/media-003.png"
|
||||
]);
|
||||
expect(result.map.image[0]).toMatchObject({
|
||||
path: "media/media-001.png",
|
||||
width: "101.600mm",
|
||||
height: "50.800mm"
|
||||
});
|
||||
expect(result.map.mermaid).toHaveLength(1);
|
||||
expect(result.map.echarts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("拒绝顺序错位、无效 PNG 和图表错误", () => {
|
||||
expect(() =>
|
||||
preparePandocMedia(
|
||||
prepared([resource("image", 2, 1)])
|
||||
)
|
||||
).toThrow("顺序");
|
||||
|
||||
expect(() =>
|
||||
preparePandocMedia(
|
||||
prepared([
|
||||
{
|
||||
...resource("image", 1, 1),
|
||||
content: new Uint8Array([1, 2, 3])
|
||||
}
|
||||
])
|
||||
)
|
||||
).toThrow("有效 PNG");
|
||||
|
||||
expect(() =>
|
||||
preparePandocMedia({
|
||||
...prepared([]),
|
||||
mermaidErrors: ["图表错误"]
|
||||
})
|
||||
).toThrow("已中止转换");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { zipSync } from "fflate";
|
||||
import {
|
||||
DOCX_PANDOC_VERSION,
|
||||
type DocxRuntimeStatus
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PandocRuntime,
|
||||
probePandocRuntime,
|
||||
type PandocProcessResult,
|
||||
type PandocProcessRunner
|
||||
} from "../src/index.js";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function processResult(
|
||||
overrides: Partial<PandocProcessResult> = {}
|
||||
): PandocProcessResult {
|
||||
return {
|
||||
outcome: "completed",
|
||||
exitCode: 0,
|
||||
stdout: encoder.encode(`pandoc ${DOCX_PANDOC_VERSION}\n`),
|
||||
stderr: "",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function runnerWith(result: PandocProcessResult) {
|
||||
return vi.fn<PandocProcessRunner>().mockResolvedValue(result);
|
||||
}
|
||||
|
||||
function createReferenceDocx() {
|
||||
const xml = (value: string) => encoder.encode(value);
|
||||
return zipSync({
|
||||
"[Content_Types].xml": xml("<Types/>"),
|
||||
"_rels/.rels": xml("<Relationships/>"),
|
||||
"word/document.xml": xml("<document/>"),
|
||||
"word/fontTable.xml": xml("<fonts/>"),
|
||||
"word/numbering.xml": xml("<numbering/>"),
|
||||
"word/settings.xml": xml("<settings/>"),
|
||||
"word/styles.xml": xml("<styles/>"),
|
||||
"word/_rels/document.xml.rels": xml("<Relationships/>"),
|
||||
"word/theme/theme1.xml": xml("<theme/>")
|
||||
});
|
||||
}
|
||||
|
||||
describe("Pandoc 运行时探测", () => {
|
||||
it.each([
|
||||
["not-found", "not-found"],
|
||||
["not-executable", "not-executable"],
|
||||
["timeout", "probe-timeout"],
|
||||
["aborted", "probe-timeout"],
|
||||
["output-limit", "not-executable"]
|
||||
] satisfies Array<[PandocProcessResult["outcome"], DocxRuntimeStatus]>)(
|
||||
"将 %s 映射为 %s",
|
||||
async (outcome, expectedStatus) => {
|
||||
const resolution = await probePandocRuntime({
|
||||
configuredPath: "C:\\trusted\\pandoc.exe",
|
||||
runner: runnerWith(
|
||||
processResult({ outcome, exitCode: null, stdout: new Uint8Array() })
|
||||
)
|
||||
});
|
||||
expect(resolution.capability.status).toBe(expectedStatus);
|
||||
}
|
||||
);
|
||||
|
||||
it("拒绝不同补丁版本", async () => {
|
||||
const resolution = await probePandocRuntime({
|
||||
configuredPath: "C:\\trusted\\pandoc.exe",
|
||||
runner: runnerWith(
|
||||
processResult({
|
||||
stdout: encoder.encode("pandoc 3.9.0.1\n")
|
||||
})
|
||||
)
|
||||
});
|
||||
expect(resolution.capability).toMatchObject({
|
||||
status: "version-mismatch",
|
||||
detectedVersion: "3.9.0.1"
|
||||
});
|
||||
});
|
||||
|
||||
it("显式路径失败时不静默回退 PATH", async () => {
|
||||
const runner = runnerWith(
|
||||
processResult({
|
||||
outcome: "not-found",
|
||||
exitCode: null,
|
||||
stdout: new Uint8Array()
|
||||
})
|
||||
);
|
||||
const resolution = await probePandocRuntime({
|
||||
configuredPath: "C:\\missing\\pandoc.exe",
|
||||
runner
|
||||
});
|
||||
expect(resolution.capability.status).toBe("not-found");
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("内置路径不存在时回退到 PATH", async () => {
|
||||
const runner = vi
|
||||
.fn<PandocProcessRunner>()
|
||||
.mockResolvedValueOnce(
|
||||
processResult({
|
||||
outcome: "not-found",
|
||||
exitCode: null,
|
||||
stdout: new Uint8Array()
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(processResult());
|
||||
const resolution = await probePandocRuntime({
|
||||
platform: "win32",
|
||||
architecture: "x64",
|
||||
desktopResourcesPath: "C:\\app\\resources",
|
||||
environment: {},
|
||||
runner
|
||||
});
|
||||
expect(resolution).toMatchObject({
|
||||
capability: { status: "available" },
|
||||
executablePath: "pandoc.exe"
|
||||
});
|
||||
expect(runner.mock.calls[0]?.[0]).toContain(
|
||||
`pandoc\\${DOCX_PANDOC_VERSION}\\windows-x86_64\\pandoc.exe`
|
||||
);
|
||||
});
|
||||
|
||||
it("缓存探测和默认 reference.docx,并向调用方返回副本", async () => {
|
||||
const reference = createReferenceDocx();
|
||||
const runner = vi
|
||||
.fn<PandocProcessRunner>()
|
||||
.mockResolvedValueOnce(processResult())
|
||||
.mockResolvedValueOnce(
|
||||
processResult({ stdout: reference })
|
||||
);
|
||||
const runtime = new PandocRuntime({
|
||||
configuredPath: "C:\\trusted\\pandoc.exe",
|
||||
runner
|
||||
});
|
||||
const first = await runtime.getDefaultReferenceDocx();
|
||||
first[0] = 0;
|
||||
const second = await runtime.getDefaultReferenceDocx();
|
||||
|
||||
expect(second[0]).not.toBe(0);
|
||||
expect(runner).toHaveBeenCalledTimes(2);
|
||||
expect(runner.mock.calls[1]?.[1]).toEqual([
|
||||
"--print-default-data-file",
|
||||
"reference.docx"
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
import {
|
||||
defaultExportConfig,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDynamicReferenceDocx,
|
||||
validateGeneratedDocx,
|
||||
validateDynamicReferenceDocx,
|
||||
writeReferenceDocxPackage
|
||||
} from "../src/index.js";
|
||||
@@ -272,4 +274,27 @@ describe("动态 reference.docx", () => {
|
||||
/footerReference 引用了无效关系/u
|
||||
);
|
||||
});
|
||||
|
||||
it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => {
|
||||
const result = createDynamicReferenceDocx(
|
||||
createBaselineReference(),
|
||||
createOptions(defaultExportConfig)
|
||||
);
|
||||
const entries = unzipSync(result.content);
|
||||
const generated = zipSync(
|
||||
{
|
||||
...entries,
|
||||
"word/media/large-test.bin": randomBytes(3 * 1024 * 1024)
|
||||
},
|
||||
{ level: 0 }
|
||||
);
|
||||
|
||||
expect(generated.byteLength).toBeGreaterThan(2 * 1024 * 1024);
|
||||
expect(validateGeneratedDocx(generated).partCount).toBe(
|
||||
result.partCount + 1
|
||||
);
|
||||
expect(() => validateDynamicReferenceDocx(generated)).toThrow(
|
||||
/大小超过限制/u
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from "../src/index.js";
|
||||
|
||||
function createReferencePackage(
|
||||
overrides: Record<string, Uint8Array> = {}
|
||||
overrides: Record<string, Uint8Array> = {},
|
||||
mtime = new Date("2020-01-01T00:00:00.000Z")
|
||||
) {
|
||||
const entries = Object.fromEntries(
|
||||
requiredReferenceDocxParts.map((name) => [
|
||||
@@ -14,14 +15,25 @@ function createReferencePackage(
|
||||
new TextEncoder().encode(`<part name="${name}"/>`)
|
||||
])
|
||||
);
|
||||
return zipSync({ ...entries, ...overrides });
|
||||
return zipSync(
|
||||
Object.fromEntries(
|
||||
Object.entries({ ...entries, ...overrides }).map(
|
||||
([name, content]) => [name, [content, { mtime }]]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe("reference.docx 包", () => {
|
||||
it("预检中央目录并返回稳定指纹", () => {
|
||||
const content = createReferencePackage();
|
||||
const first = readReferenceDocxPackage(content);
|
||||
const second = readReferenceDocxPackage(content);
|
||||
const second = readReferenceDocxPackage(
|
||||
createReferencePackage(
|
||||
{},
|
||||
new Date("2026-07-30T00:00:00.000Z")
|
||||
)
|
||||
);
|
||||
|
||||
expect(first.entries.size).toBe(requiredReferenceDocxParts.length);
|
||||
expect(first.entries.has("word/styles.xml")).toBe(true);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { zipSync } from "fflate";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const wordNamespace =
|
||||
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
|
||||
function xml(value: string) {
|
||||
return encoder.encode(value);
|
||||
}
|
||||
|
||||
export function createTestBaselineReference() {
|
||||
const styleIds = [
|
||||
"Normal",
|
||||
"BodyText",
|
||||
"FirstParagraph",
|
||||
"Compact",
|
||||
"Title",
|
||||
"TitleChar",
|
||||
"Subtitle",
|
||||
"SubtitleChar",
|
||||
"Heading1",
|
||||
"Heading1Char",
|
||||
"Heading2",
|
||||
"Heading2Char",
|
||||
"Heading3",
|
||||
"Heading3Char",
|
||||
"Heading4",
|
||||
"Heading4Char",
|
||||
"Heading5",
|
||||
"Heading5Char",
|
||||
"Heading6",
|
||||
"Heading6Char",
|
||||
"BlockText",
|
||||
"FootnoteText",
|
||||
"DefaultParagraphFont",
|
||||
"Table",
|
||||
"Definition",
|
||||
"Caption",
|
||||
"TableCaption",
|
||||
"ImageCaption",
|
||||
"Figure",
|
||||
"BodyTextChar",
|
||||
"VerbatimChar",
|
||||
"Hyperlink"
|
||||
];
|
||||
const styles = styleIds
|
||||
.map((styleId) => {
|
||||
const type =
|
||||
styleId === "Table"
|
||||
? "table"
|
||||
: styleId.endsWith("Char") ||
|
||||
styleId === "DefaultParagraphFont" ||
|
||||
styleId === "VerbatimChar" ||
|
||||
styleId === "Hyperlink"
|
||||
? "character"
|
||||
: "paragraph";
|
||||
return `<w:style w:type="${type}" w:styleId="${styleId}"><w:name w:val="${styleId}"/></w:style>`;
|
||||
})
|
||||
.join("");
|
||||
return zipSync({
|
||||
"[Content_Types].xml": xml(
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
|
||||
),
|
||||
"_rels/.rels": xml(
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
|
||||
),
|
||||
"word/document.xml": xml(
|
||||
`<w:document xmlns:w="${wordNamespace}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p/><w:sectPr><w:footnotePr/></w:sectPr></w:body></w:document>`
|
||||
),
|
||||
"word/fontTable.xml": xml(
|
||||
`<w:fonts xmlns:w="${wordNamespace}"><w:font w:name="Arial"/></w:fonts>`
|
||||
),
|
||||
"word/numbering.xml": xml(
|
||||
`<w:numbering xmlns:w="${wordNamespace}"/>`
|
||||
),
|
||||
"word/settings.xml": xml(
|
||||
`<w:settings xmlns:w="${wordNamespace}"/>`
|
||||
),
|
||||
"word/styles.xml": xml(
|
||||
`<w:styles xmlns:w="${wordNamespace}"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>${styles}</w:styles>`
|
||||
),
|
||||
"word/_rels/document.xml.rels": xml(
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'
|
||||
),
|
||||
"word/theme/theme1.xml": xml(
|
||||
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:minorFont></a:fontScheme></a:themeElements></a:theme>'
|
||||
)
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user