diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs
index a38aed7aa..b4e15f174 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs
@@ -151,7 +151,7 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[
},
BuiltinMiniAppBundle {
id: "builtin-ppt-live",
- version: 247,
+ version: 252,
meta_json: include_str!("builtin/assets/ppt-live/meta.json"),
html: include_str!("builtin/assets/ppt-live/index.html"),
css: include_str!("builtin/assets/ppt-live/style.css"),
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json
index 2752fb2c8..6fbc766b3 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"id": "builtin-ppt-live",
- "version": 247
+ "version": 252
}
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js
index 1232cc786..51e6bcf4f 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js
@@ -14873,7 +14873,7 @@ function textNode(sourceId, box, text2, style, theme, paintOrder, subOrder = 1)
subOrder,
style: {
margin: 0.08,
- fontFace: "Aptos",
+ fontFace: "Microsoft YaHei",
fontSize: pxToPt(style.fontSize || 22),
bold: Number(style.fontWeight || 500) >= 700,
color: resolveColor(style.color || "ink", theme),
@@ -36541,6 +36541,34 @@ var PptxGenJS = class {
var import_jszip2 = __toESM(require_jszip_min(), 1);
var SLIDE_H_IN = 7.5;
var OOXML_ROUND_RECT_ADJUSTMENTS = /* @__PURE__ */ Symbol("ppt-live-round-rect-adjustments");
+var PPTX_LATIN_FONT_FACE = "Arial";
+var PPTX_CJK_FONT_FACE = "Microsoft YaHei";
+var EMPTY_SHAPE_TX_BODY = ``;
+var PLATFORM_CJK_FONT_ALIASES = /* @__PURE__ */ new Set([
+ "pingfang sc",
+ "pingfang tc",
+ "hiragino sans gb",
+ "hiragino sans",
+ "stheiti",
+ "heiti sc",
+ "heiti tc",
+ "songti sc",
+ "source han sans sc",
+ "source han sans cn",
+ "noto sans cjk sc",
+ "noto sans sc",
+ "wenquanyi micro hei",
+ "wenquanyi zen hei",
+ "droid sans fallback",
+ "\u5FAE\u8F6F\u96C5\u9ED1",
+ "microsoft yahei",
+ "microsoft yahei ui",
+ "simhei",
+ "simsun",
+ "nsimsun",
+ "kaiti",
+ "fangsong"
+]);
var WIDTH_SAFETY_BASE_IN = 0.36;
var WIDTH_SAFETY_REF_PT = 14;
var WIDTH_SAFETY_MIN_IN = 0.28;
@@ -36615,22 +36643,56 @@ var CSS_SYSTEM_FONT_FACES = /* @__PURE__ */ new Set([
function textHintContainsCjk(textHint) {
return /[\u4e00-\u9fff]/.test(String(textHint || ""));
}
+function isPlatformCjkFontFace(fontFace) {
+ const lower = String(fontFace || "").replace(/['"]/g, "").trim().toLowerCase();
+ return PLATFORM_CJK_FONT_ALIASES.has(lower);
+}
function resolvePptxFontFace(fontFace, textHint = "") {
const face = String(fontFace || "").replace(/['"]/g, "").trim();
- if (!face) return "PingFang SC";
+ if (!face) {
+ return textHintContainsCjk(textHint) ? PPTX_CJK_FONT_FACE : PPTX_LATIN_FONT_FACE;
+ }
const lower = face.toLowerCase();
if (CSS_SYSTEM_FONT_FACES.has(lower) || lower.startsWith(".")) {
- return "PingFang SC";
+ return textHintContainsCjk(textHint) ? PPTX_CJK_FONT_FACE : PPTX_LATIN_FONT_FACE;
}
- if (textHintContainsCjk(textHint) && (lower === "arial" || lower === "helvetica" || lower === "times new roman")) {
- return "PingFang SC";
+ if (isPlatformCjkFontFace(face) || lower === "aptos") {
+ if (lower === "aptos") {
+ return textHintContainsCjk(textHint) ? PPTX_CJK_FONT_FACE : PPTX_LATIN_FONT_FACE;
+ }
+ return PPTX_CJK_FONT_FACE;
+ }
+ if (textHintContainsCjk(textHint) && (lower === "arial" || lower === "helvetica" || lower === "times new roman" || lower === "calibri" || lower === "georgia" || lower === "verdana" || lower === "tahoma" || lower === "trebuchet ms")) {
+ return PPTX_CJK_FONT_FACE;
}
return face;
}
+function resolveCrossPlatformFontPair(fontFace) {
+ const face = String(fontFace || "").replace(/['"]/g, "").trim();
+ if (!face) {
+ return {
+ latin: PPTX_LATIN_FONT_FACE,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: PPTX_LATIN_FONT_FACE
+ };
+ }
+ if (isPlatformCjkFontFace(face) || face === PPTX_CJK_FONT_FACE) {
+ return {
+ latin: PPTX_LATIN_FONT_FACE,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: PPTX_LATIN_FONT_FACE
+ };
+ }
+ return {
+ latin: face,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: face
+ };
+}
function withResolvedFontFace(options = {}, textHint = "") {
if (!options || typeof options !== "object") return options;
if (options.fontFace == null && options.fontFamily == null) {
- return textHintContainsCjk(textHint) ? { ...options, fontFace: "PingFang SC" } : options;
+ return textHintContainsCjk(textHint) ? { ...options, fontFace: PPTX_CJK_FONT_FACE } : options;
}
return {
...options,
@@ -36851,26 +36913,144 @@ function uniquifySlideObjectIds(xml) {
let nextId = 1;
return xml.replace(/ `]*\/>/g,
+ ""
+ ).replace(
+ 'ContentType="image/jpg"',
+ 'ContentType="image/jpeg"'
+ );
+}
+function stripNotesMasterPlaceholderShapes(xml) {
+ return String(xml || "").replace(/[\s\S]*?<\/p:sp>/g, "");
+}
+function ensureShapeTextBodies(xml) {
+ return String(xml || "").replace(/([\s\S]*?)<\/p:sp>/g, (match, body) => {
+ if (body.includes("${body}${EMPTY_SHAPE_TX_BODY}`;
+ });
+}
+function ensureLineNoFill(xml) {
+ return String(xml || "").replace(/]*)\/>/g, "").replace(/]*)>\s*<\/a:ln>/g, "");
+}
+function ensureSolidBackgroundEffectList(xml) {
+ return String(xml || "").replace(/([\s\S]*?)<\/p:bgPr>/g, (match, body) => {
+ if (!body.includes("${body}`;
+ });
+}
+function fixNegativeExtents(xml) {
+ return String(xml || "").replace(/]*)>([\s\S]*?)<\/a:xfrm>/g, (match, attrs, body) => {
+ const off = body.match(//);
+ const ext = body.match(//);
+ if (!off || !ext) return match;
+ let x = Number(off[1]);
+ let y = Number(off[2]);
+ let cx2 = Number(ext[1]);
+ let cy2 = Number(ext[2]);
+ if (![x, y, cx2, cy2].every(Number.isFinite) || cx2 >= 0 && cy2 >= 0) return match;
+ let flipH = /\bflipH="1"/.test(attrs);
+ let flipV = /\bflipV="1"/.test(attrs);
+ if (cx2 < 0) {
+ x += cx2;
+ cx2 = Math.abs(cx2);
+ flipH = !flipH;
+ }
+ if (cy2 < 0) {
+ y += cy2;
+ cy2 = Math.abs(cy2);
+ flipV = !flipV;
+ }
+ let nextAttrs = String(attrs || "").replace(/\s*flipH="1"/g, "").replace(/\s*flipV="1"/g, "");
+ if (flipH) nextAttrs += ' flipH="1"';
+ if (flipV) nextAttrs += ' flipV="1"';
+ const nextBody = body.replace(//, ``).replace(//, ``);
+ return `${nextBody}`;
+ });
+}
+function fixInvalidTableCellAnchors(xml) {
+ return String(xml || "").replace(
+ /]*)>/g,
+ (match, attrs) => ``
+ );
+}
+function formatFontSlot(slot, typeface, attrs) {
+ const cleaned = String(attrs || "").replace(/\/\s*$/, "");
+ return ``;
+}
+function normalizeOoxmlFonts(xml) {
+ let next = String(xml || "");
+ next = next.replace(
+ /]*)\/?>\s*]*)\/?>\s*]*)\/?>/g,
+ (_match, latinFace, latinAttrs, eaFace, eaAttrs, _csFace, csAttrs) => {
+ const sourceFace = latinFace || eaFace || "";
+ const pair = resolveCrossPlatformFontPair(sourceFace);
+ return formatFontSlot("latin", pair.latin, latinAttrs) + formatFontSlot("ea", pair.ea, eaAttrs) + formatFontSlot("cs", pair.cs, csAttrs);
+ }
+ );
+ next = next.replace(/|>[\s\S]*?<\/a:avLst>)<\/a:prstGeom>/g,
+ (match) => {
+ const adjustment = adjustments[adjustmentIndex];
+ adjustmentIndex += 1;
+ if (adjustment == null) return match;
+ return ``;
+ }
+ );
+ return { xml: next, adjustmentIndex };
+}
async function postProcessPptxOutput(output, outputType, adjustments) {
if (!["base64", "nodebuffer"].includes(outputType)) return output;
const needsRoundRect = adjustments.length > 0;
const zip = await import_jszip2.default.loadAsync(output, { base64: outputType === "base64" });
- const slidePaths = Object.keys(zip.files).filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort((left, right) => Number(left.match(/\d+/)?.[0]) - Number(right.match(/\d+/)?.[0]));
+ const contentTypes = zip.file("[Content_Types].xml");
+ if (contentTypes) {
+ zip.file("[Content_Types].xml", fixContentTypesXml(await contentTypes.async("string")));
+ }
+ const xmlPaths = Object.keys(zip.files).filter((path) => path.endsWith(".xml") && !path.endsWith("/")).sort();
let adjustmentIndex = 0;
- for (const path of slidePaths) {
+ for (const path of xmlPaths) {
+ if (path === "[Content_Types].xml") continue;
let xml = await zip.file(path).async("string");
- if (needsRoundRect) {
- xml = xml.replace(
- /|>[\s\S]*?<\/a:avLst>)<\/a:prstGeom>/g,
- (match) => {
- const adjustment = adjustments[adjustmentIndex];
- adjustmentIndex += 1;
- if (adjustment == null) return match;
- return ``;
- }
- );
+ const isSlide = /^ppt\/slides\/slide\d+\.xml$/.test(path);
+ const isNotesSlide = /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(path);
+ if (path === "ppt/notesMasters/notesMaster1.xml") {
+ xml = stripNotesMasterPlaceholderShapes(xml);
+ xml = ensureSolidBackgroundEffectList(xml);
+ xml = ensureLineNoFill(xml);
+ xml = normalizeOoxmlFonts(xml);
+ zip.file(path, xml);
+ continue;
+ }
+ if (isSlide && needsRoundRect) {
+ ({ xml, adjustmentIndex } = applyRoundRectAdjustments(xml, adjustments, adjustmentIndex));
+ }
+ if (isSlide || isNotesSlide) {
+ xml = ensureShapeTextBodies(xml);
+ }
+ if (isSlide) {
+ xml = uniquifySlideObjectIds(xml);
+ xml = fixNegativeExtents(xml);
+ xml = fixInvalidTableCellAnchors(xml);
+ }
+ if (isSlide || isNotesSlide || path === "ppt/slideMasters/slideMaster1.xml" || /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) {
+ xml = ensureSolidBackgroundEffectList(xml);
+ }
+ if (isSlide || isNotesSlide) {
+ xml = ensureLineNoFill(xml);
}
- xml = uniquifySlideObjectIds(xml);
+ xml = normalizeOoxmlFonts(xml);
zip.file(path, xml);
}
if (needsRoundRect && adjustmentIndex !== adjustments.length) {
@@ -36890,8 +37070,8 @@ function createPptxDeck(deck = {}) {
pptx.company = "BitFun";
pptx.lang = "zh-CN";
pptx.theme = {
- headFontFace: "PingFang SC",
- bodyFontFace: "PingFang SC",
+ headFontFace: PPTX_LATIN_FONT_FACE,
+ bodyFontFace: PPTX_LATIN_FONT_FACE,
lang: "zh-CN"
};
pptx[OOXML_ROUND_RECT_ADJUSTMENTS] = [];
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json
index 02bbb6f94..8cf9df39d 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json
@@ -10,7 +10,7 @@
"ppt",
"ai"
],
- "version": 247,
+ "version": 252,
"created_at": 0,
"updated_at": 0,
"permissions": {
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js
index 0b2887823..057a185a2 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js
@@ -84,7 +84,7 @@ function textNode(sourceId, box, text, style, theme, paintOrder, subOrder = 1) {
subOrder,
style: {
margin: 0.08,
- fontFace: 'Aptos',
+ fontFace: 'Microsoft YaHei',
fontSize: pxToPt(style.fontSize || 22),
bold: Number(style.fontWeight || 500) >= 700,
color: resolveColor(style.color || 'ink', theme),
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js
index 73d66d2b3..6f3b5508e 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js
@@ -24,6 +24,42 @@ const SLIDE_W_IN = 13.333;
const SLIDE_H_IN = 7.5;
const OOXML_ROUND_RECT_ADJUSTMENTS = Symbol('ppt-live-round-rect-adjustments');
+// Cross-platform PPTX fonts. PingFang SC is macOS-only — Windows PowerPoint
+// substitutes poorly after a repair pass and CJK runs often become mojibake.
+// Microsoft YaHei is present on Windows Office and maps cleanly on Mac/Linux
+// Office / LibreOffice; Arial covers Latin glyphs on all three platforms.
+export const PPTX_LATIN_FONT_FACE = 'Arial';
+export const PPTX_CJK_FONT_FACE = 'Microsoft YaHei';
+
+const EMPTY_SHAPE_TX_BODY = ''
+ + ``;
+
+const PLATFORM_CJK_FONT_ALIASES = new Set([
+ 'pingfang sc',
+ 'pingfang tc',
+ 'hiragino sans gb',
+ 'hiragino sans',
+ 'stheiti',
+ 'heiti sc',
+ 'heiti tc',
+ 'songti sc',
+ 'source han sans sc',
+ 'source han sans cn',
+ 'noto sans cjk sc',
+ 'noto sans sc',
+ 'wenquanyi micro hei',
+ 'wenquanyi zen hei',
+ 'droid sans fallback',
+ '微软雅黑',
+ 'microsoft yahei',
+ 'microsoft yahei ui',
+ 'simhei',
+ 'simsun',
+ 'nsimsun',
+ 'kaiti',
+ 'fangsong',
+]);
+
// PowerPoint and browsers render the same font at the same point size with
// measurably different glyph widths (different font metric tables / hinting).
// For CJK text the drift is amplified because every glyph is full-width:
@@ -138,30 +174,77 @@ function textHintContainsCjk(textHint) {
return /[\u4e00-\u9fff]/.test(String(textHint || ''));
}
-function resolvePptxFontFace(fontFace, textHint = '') {
+function isPlatformCjkFontFace(fontFace) {
+ const lower = String(fontFace || '').replace(/['"]/g, '').trim().toLowerCase();
+ return PLATFORM_CJK_FONT_ALIASES.has(lower);
+}
+
+/**
+ * Resolve a CSS/computed face to a single pptxgenjs fontFace.
+ * PptxGenJS writes the same typeface into latin/ea/cs; postProcess then splits
+ * CJK faces into Arial (latin/cs) + Microsoft YaHei (ea) for Win/Mac/Linux.
+ */
+export function resolvePptxFontFace(fontFace, textHint = '') {
const face = String(fontFace || '').replace(/['"]/g, '').trim();
- if (!face) return 'PingFang SC';
+ if (!face) {
+ return textHintContainsCjk(textHint) ? PPTX_CJK_FONT_FACE : PPTX_LATIN_FONT_FACE;
+ }
const lower = face.toLowerCase();
if (CSS_SYSTEM_FONT_FACES.has(lower) || lower.startsWith('.')) {
- return 'PingFang SC';
+ return textHintContainsCjk(textHint) ? PPTX_CJK_FONT_FACE : PPTX_LATIN_FONT_FACE;
+ }
+ if (isPlatformCjkFontFace(face) || lower === 'aptos') {
+ // Aptos is Windows 11 Office-only; map to the cross-platform CJK stack when
+ // the run may contain CJK, otherwise keep Latin-safe Arial.
+ if (lower === 'aptos') {
+ return textHintContainsCjk(textHint) ? PPTX_CJK_FONT_FACE : PPTX_LATIN_FONT_FACE;
+ }
+ return PPTX_CJK_FONT_FACE;
}
// PptxGenJS writes the same typeface into latin/ea/cs. Latin-only faces such
// as Arial therefore lock East-Asian glyphs to tofu after PowerPoint opens
// (or repairs) the table. Prefer the deck CJK body font for CJK runs.
if (
textHintContainsCjk(textHint)
- && (lower === 'arial' || lower === 'helvetica' || lower === 'times new roman')
+ && (lower === 'arial' || lower === 'helvetica' || lower === 'times new roman'
+ || lower === 'calibri' || lower === 'georgia' || lower === 'verdana'
+ || lower === 'tahoma' || lower === 'trebuchet ms')
) {
- return 'PingFang SC';
+ return PPTX_CJK_FONT_FACE;
}
return face;
}
+/** Split a resolved face into OOXML latin / ea / cs typefaces. */
+export function resolveCrossPlatformFontPair(fontFace) {
+ const face = String(fontFace || '').replace(/['"]/g, '').trim();
+ if (!face) {
+ return {
+ latin: PPTX_LATIN_FONT_FACE,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: PPTX_LATIN_FONT_FACE,
+ };
+ }
+ if (isPlatformCjkFontFace(face) || face === PPTX_CJK_FONT_FACE) {
+ return {
+ latin: PPTX_LATIN_FONT_FACE,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: PPTX_LATIN_FONT_FACE,
+ };
+ }
+ // Keep designer / Latin faces for Western glyphs; always give CJK a fallback.
+ return {
+ latin: face,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: face,
+ };
+}
+
function withResolvedFontFace(options = {}, textHint = '') {
if (!options || typeof options !== 'object') return options;
if (options.fontFace == null && options.fontFamily == null) {
return textHintContainsCjk(textHint)
- ? { ...options, fontFace: 'PingFang SC' }
+ ? { ...options, fontFace: PPTX_CJK_FONT_FACE }
: options;
}
return {
@@ -412,32 +495,203 @@ function uniquifySlideObjectIds(xml) {
return xml.replace(/ `]*\/>/g,
+ '',
+ )
+ .replace(
+ 'ContentType="image/jpg"',
+ 'ContentType="image/jpeg"',
+ );
+}
+
+// Placeholder shapes on notesMaster are stripped by PowerPoint repair
+// (gitbrent/PptxGenJS#1443). Per-slide notesSlide parts keep their own ph shapes.
+function stripNotesMasterPlaceholderShapes(xml) {
+ return String(xml || '').replace(/[\s\S]*?<\/p:sp>/g, '');
+}
+
+// OOXML requires every to contain . Decorative shapes from
+// addShape() omit it (gitbrent/PptxGenJS#1441), which also triggers repair.
+// notesSlide "Slide Image Placeholder" ships the same defect on every deck.
+export function ensureShapeTextBodies(xml) {
+ return String(xml || '').replace(/([\s\S]*?)<\/p:sp>/g, (match, body) => {
+ if (body.includes('${body}${EMPTY_SHAPE_TX_BODY}`;
+ });
+}
+
+// PptxGenJS emits empty for shapes without a border. PowerPoint
+// repair rewrites these; normalize to an explicit noFill line.
+export function ensureLineNoFill(xml) {
+ return String(xml || '')
+ .replace(/]*)\/>/g, '')
+ .replace(/]*)>\s*<\/a:ln>/g, '');
+}
+
+// Solid slide backgrounds from pptxgen omit inside
+// (gitbrent/PptxGenJS#1442). Image backgrounds already include it; solid ones
+// must match or PowerPoint may repair the package.
+export function ensureSolidBackgroundEffectList(xml) {
+ return String(xml || '').replace(/([\s\S]*?)<\/p:bgPr>/g, (match, body) => {
+ if (!body.includes('${body}`;
+ });
+}
+
+// OOXML ST_PositiveCoordinate forbids negative a:ext cx/cy. Upward/leftward
+// lines from html2pptx often serialize as negative extents; PowerPoint repair
+// clamps them to 0. Rewrite as positive extents + flipH/flipV.
+export function fixNegativeExtents(xml) {
+ return String(xml || '').replace(/]*)>([\s\S]*?)<\/a:xfrm>/g, (match, attrs, body) => {
+ const off = body.match(//);
+ const ext = body.match(//);
+ if (!off || !ext) return match;
+ let x = Number(off[1]);
+ let y = Number(off[2]);
+ let cx = Number(ext[1]);
+ let cy = Number(ext[2]);
+ if (![x, y, cx, cy].every(Number.isFinite) || (cx >= 0 && cy >= 0)) return match;
+ let flipH = /\bflipH="1"/.test(attrs);
+ let flipV = /\bflipV="1"/.test(attrs);
+ if (cx < 0) {
+ x += cx;
+ cx = Math.abs(cx);
+ flipH = !flipH;
+ }
+ if (cy < 0) {
+ y += cy;
+ cy = Math.abs(cy);
+ flipV = !flipV;
+ }
+ let nextAttrs = String(attrs || '')
+ .replace(/\s*flipH="1"/g, '')
+ .replace(/\s*flipV="1"/g, '');
+ if (flipH) nextAttrs += ' flipH="1"';
+ if (flipV) nextAttrs += ' flipV="1"';
+ const nextBody = body
+ .replace(//, ``)
+ .replace(//, ``);
+ return `${nextBody}`;
+ });
+}
+
+// PptxGenJS maps valign "mid" to OOXML anchor="mid", but ST_TextAnchoringType
+// only allows t/ctr/b. Invalid mid on a:tcPr triggers PowerPoint repair.
+export function fixInvalidTableCellAnchors(xml) {
+ return String(xml || '').replace(
+ /]*)>/g,
+ (match, attrs) => ``,
+ );
+}
+
+function formatFontSlot(slot, typeface, attrs) {
+ const cleaned = String(attrs || '').replace(/\/\s*$/, '');
+ return ``;
+}
+
+/**
+ * Normalize DrawingML font slots for Win/Mac/Linux:
+ * - fill empty theme ea/cs
+ * - split identical latin/ea/cs triplets so CJK uses Microsoft YaHei on ea
+ * while Latin stays on Arial (or the designer latin face)
+ */
+export function normalizeOoxmlFonts(xml) {
+ let next = String(xml || '');
+ next = next.replace(
+ /]*)\/?>\s*]*)\/?>\s*]*)\/?>/g,
+ (_match, latinFace, latinAttrs, eaFace, eaAttrs, _csFace, csAttrs) => {
+ const sourceFace = latinFace || eaFace || '';
+ const pair = resolveCrossPlatformFontPair(sourceFace);
+ return (
+ formatFontSlot('latin', pair.latin, latinAttrs)
+ + formatFontSlot('ea', pair.ea, eaAttrs)
+ + formatFontSlot('cs', pair.cs, csAttrs)
+ );
+ },
+ );
+ // Theme often ships `` beside latin.
+ next = next.replace(/|>[\s\S]*?<\/a:avLst>)<\/a:prstGeom>/g,
+ (match) => {
+ const adjustment = adjustments[adjustmentIndex];
+ adjustmentIndex += 1;
+ if (adjustment == null) return match;
+ return ''
+ + ``
+ + '';
+ },
+ );
+ return { xml: next, adjustmentIndex };
+}
+
async function postProcessPptxOutput(output, outputType, adjustments) {
if (!['base64', 'nodebuffer'].includes(outputType)) return output;
const needsRoundRect = adjustments.length > 0;
const zip = await JSZip.loadAsync(output, { base64: outputType === 'base64' });
- const slidePaths = Object.keys(zip.files)
- .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
- .sort((left, right) => (
- Number(left.match(/\d+/)?.[0]) - Number(right.match(/\d+/)?.[0])
- ));
+
+ const contentTypes = zip.file('[Content_Types].xml');
+ if (contentTypes) {
+ zip.file('[Content_Types].xml', fixContentTypesXml(await contentTypes.async('string')));
+ }
+
+ const xmlPaths = Object.keys(zip.files)
+ .filter((path) => path.endsWith('.xml') && !path.endsWith('/'))
+ .sort();
let adjustmentIndex = 0;
- for (const path of slidePaths) {
+ for (const path of xmlPaths) {
+ if (path === '[Content_Types].xml') continue;
let xml = await zip.file(path).async('string');
- if (needsRoundRect) {
- xml = xml.replace(
- /|>[\s\S]*?<\/a:avLst>)<\/a:prstGeom>/g,
- (match) => {
- const adjustment = adjustments[adjustmentIndex];
- adjustmentIndex += 1;
- if (adjustment == null) return match;
- return ''
- + ``
- + '';
- },
- );
+ const isSlide = /^ppt\/slides\/slide\d+\.xml$/.test(path);
+ const isNotesSlide = /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(path);
+ if (path === 'ppt/notesMasters/notesMaster1.xml') {
+ xml = stripNotesMasterPlaceholderShapes(xml);
+ xml = ensureSolidBackgroundEffectList(xml);
+ xml = ensureLineNoFill(xml);
+ xml = normalizeOoxmlFonts(xml);
+ zip.file(path, xml);
+ continue;
+ }
+ if (isSlide && needsRoundRect) {
+ ({ xml, adjustmentIndex } = applyRoundRectAdjustments(xml, adjustments, adjustmentIndex));
+ }
+ if (isSlide || isNotesSlide) {
+ xml = ensureShapeTextBodies(xml);
+ }
+ if (isSlide) {
+ xml = uniquifySlideObjectIds(xml);
+ xml = fixNegativeExtents(xml);
+ xml = fixInvalidTableCellAnchors(xml);
+ }
+ if (
+ isSlide
+ || isNotesSlide
+ || path === 'ppt/slideMasters/slideMaster1.xml'
+ || /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)
+ ) {
+ xml = ensureSolidBackgroundEffectList(xml);
+ }
+ if (isSlide || isNotesSlide) {
+ xml = ensureLineNoFill(xml);
}
- xml = uniquifySlideObjectIds(xml);
+ xml = normalizeOoxmlFonts(xml);
zip.file(path, xml);
}
if (needsRoundRect && adjustmentIndex !== adjustments.length) {
@@ -458,8 +712,8 @@ export function createPptxDeck(deck = {}) {
pptx.company = 'BitFun';
pptx.lang = 'zh-CN';
pptx.theme = {
- headFontFace: 'PingFang SC',
- bodyFontFace: 'PingFang SC',
+ headFontFace: PPTX_LATIN_FONT_FACE,
+ bodyFontFace: PPTX_LATIN_FONT_FACE,
lang: 'zh-CN',
};
pptx[OOXML_ROUND_RECT_ADJUSTMENTS] = [];
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs
index 8290f0593..e9dcf6c19 100644
--- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs
+++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs
@@ -717,7 +717,8 @@ test('serializes an editable table scene node as native a:tbl OOXML without pict
.map((match) => match[0]);
const headerCellXml = tableCells.find((cellXml) => cellXml.includes('Native '));
assert.ok(headerCellXml);
- assert.match(headerCellXml, /]*marL="182880"[^>]*marR="91440"[^>]*marT="45720"[^>]*marB="137160"[^>]*anchor="mid"/);
+ // OOXML ST_TextAnchoringType uses ctr (not mid); post-process rewrites mid→ctr.
+ assert.match(headerCellXml, /]*marL="182880"[^>]*marR="91440"[^>]*marT="45720"[^>]*marB="137160"[^>]*anchor="ctr"/);
assert.match(headerCellXml, /阶段<\/a:t>/);
}, { realisticLayout: true, webkitBorderBoxRegressionSimulation: true });
});
@@ -2185,7 +2187,7 @@ test('text box width safety scales with font size to prevent false wraps', async
text: '大号标题防换行',
style: {
fontSize: 42,
- fontFace: 'PingFang SC',
+ fontFace: 'Microsoft YaHei',
color: '111111',
align: 'left',
valign: 'top',
@@ -2202,6 +2204,196 @@ test('text box width safety scales with font size to prevent false wraps', async
assert.match(titleShape, new RegExp(` {
+ const {
+ createPptxDeck,
+ buildSlideFromScene,
+ PPTX_CJK_FONT_FACE,
+ PPTX_LATIN_FONT_FACE,
+ resolvePptxFontFace,
+ resolveCrossPlatformFontPair,
+ normalizeOoxmlFonts,
+ } = await import('../src/pptx-html-build.js');
+
+ assert.equal(resolvePptxFontFace('PingFang SC'), PPTX_CJK_FONT_FACE);
+ assert.equal(resolvePptxFontFace('system-ui', '中文'), PPTX_CJK_FONT_FACE);
+ assert.equal(resolvePptxFontFace('Arial', '中文'), PPTX_CJK_FONT_FACE);
+ assert.deepEqual(resolveCrossPlatformFontPair('PingFang SC'), {
+ latin: PPTX_LATIN_FONT_FACE,
+ ea: PPTX_CJK_FONT_FACE,
+ cs: PPTX_LATIN_FONT_FACE,
+ });
+ assert.match(
+ normalizeOoxmlFonts(''),
+ new RegExp(`typeface="${PPTX_CJK_FONT_FACE}"`),
+ );
+
+ const pptx = createPptxDeck({ title: 'Repair triggers' });
+ for (let index = 0; index < 3; index += 1) {
+ await buildSlideFromScene({
+ slideNumber: index + 1,
+ width: 13.333,
+ height: 7.5,
+ nodes: [
+ {
+ type: 'shape',
+ sourceId: `bg-${index}`,
+ shapeType: 'rect',
+ x: 0.5,
+ y: 0.5,
+ w: 2,
+ h: 1,
+ style: { fill: 'E11D48' },
+ },
+ {
+ type: 'text',
+ sourceId: `title-${index}`,
+ x: 1,
+ y: 2,
+ w: 6,
+ h: 1,
+ text: '跨平台中文与 Latin',
+ style: {
+ fontSize: 24,
+ fontFace: 'PingFang SC',
+ color: '111111',
+ align: 'left',
+ valign: 'top',
+ },
+ },
+ ],
+ }, pptx);
+ }
+
+ const zip = await writeAndOpen(pptx);
+ const contentTypes = await zipText(zip, '[Content_Types].xml');
+ assert.equal(
+ (contentTypes.match(/slideMasters\/slideMaster\d+\.xml/g) || []).length,
+ 1,
+ 'Content_Types must declare only slideMaster1',
+ );
+ assert.doesNotMatch(contentTypes, /slideMaster2\.xml/);
+ assert.doesNotMatch(contentTypes, /ContentType="image\/jpg"/);
+
+ const notesMaster = await zipText(zip, 'ppt/notesMasters/notesMaster1.xml');
+ assert.equal((notesMaster.match(//g) || []).length, 0);
+
+ const slideXml = await zipText(zip, 'ppt/slides/slide1.xml');
+ const shapes = [...slideXml.matchAll(/[\s\S]*?<\/p:sp>/g)].map((m) => m[0]);
+ assert.ok(shapes.length >= 2, 'expected decorative shape + text box');
+ for (const shape of shapes) {
+ assert.match(shape, //, 'every p:sp must include txBody');
+ }
+ assert.match(slideXml, new RegExp(` {
+ const {
+ createPptxDeck,
+ ensureShapeTextBodies,
+ ensureLineNoFill,
+ fixNegativeExtents,
+ fixInvalidTableCellAnchors,
+ } = await import('../src/pptx-html-build.js');
+
+ const notesPlaceholder = ''
+ + '';
+ assert.match(ensureShapeTextBodies(notesPlaceholder), //);
+ assert.equal(
+ ensureLineNoFill(''),
+ '',
+ );
+ assert.match(
+ fixNegativeExtents(''),
+ //,
+ );
+ assert.match(
+ fixInvalidTableCellAnchors(''),
+ /anchor="ctr"/,
+ );
+ assert.doesNotMatch(
+ fixInvalidTableCellAnchors(''),
+ /anchor="mid"/,
+ );
+
+ const pptx = createPptxDeck({ title: 'Notes placeholder' });
+ const slide = pptx.addSlide();
+ slide.addShape(pptx.shapes.RECTANGLE, {
+ x: 0.5,
+ y: 0.5,
+ w: 2,
+ h: 1,
+ fill: { color: 'FFFFFF' },
+ });
+ slide.addText('备注页占位', {
+ x: 1,
+ y: 2,
+ w: 4,
+ h: 1,
+ fontSize: 18,
+ fontFace: 'Arial',
+ color: '111111',
+ });
+
+ const zip = await writeAndOpen(pptx);
+ const notesXml = await zipText(zip, 'ppt/notesSlides/notesSlide1.xml');
+ const notesShapes = [...notesXml.matchAll(/[\s\S]*?<\/p:sp>/g)].map((m) => m[0]);
+ assert.ok(notesShapes.length >= 1, 'notesSlide must contain shapes');
+ for (const shape of notesShapes) {
+ assert.match(shape, //, 'notesSlide shapes must include txBody');
+ }
+ const slideXml = await zipText(zip, 'ppt/slides/slide1.xml');
+ assert.doesNotMatch(slideXml, /\s*<\/a:ln>/);
+ assert.doesNotMatch(slideXml, //);
+});
+
+test('pptx post-process adds effectLst to solid slide backgrounds (#1442)', async () => {
+ const {
+ createPptxDeck,
+ ensureSolidBackgroundEffectList,
+ } = await import('../src/pptx-html-build.js');
+
+ assert.equal(
+ ensureSolidBackgroundEffectList(
+ '',
+ ),
+ '',
+ );
+ assert.equal(
+ ensureSolidBackgroundEffectList(
+ '',
+ ),
+ '',
+ 'idempotent when effectLst already present',
+ );
+
+ const pptx = createPptxDeck({ title: 'Solid background' });
+ const slide = pptx.addSlide();
+ slide.background = { color: 'F8FAFC' };
+ slide.addText('背景加固', {
+ x: 1,
+ y: 1,
+ w: 4,
+ h: 1,
+ fontSize: 20,
+ fontFace: 'Arial',
+ color: '111111',
+ });
+
+ const zip = await writeAndOpen(pptx);
+ const slideXml = await zipText(zip, 'ppt/slides/slide1.xml');
+ assert.match(
+ slideXml,
+ /<\/a:solidFill><\/p:bgPr>/,
+ );
+});
+
test('CSS letter-spacing is preserved as PPTX charSpacing', async () => {
await withControllableExportDom(async () => {
const { prepareEditableSlides } = await import('../src/export-slide-browser.js');