From a94f21943e574119c0080b36ddb947e493ec7f1c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 20 Jul 2026 11:23:40 +0800 Subject: [PATCH] fix(ppt-live): stop PowerPoint repair dialogs on exported PPTX Post-process pptxgenjs OOXML to remove known repair triggers and use cross-platform Arial + Microsoft YaHei fonts so CJK survives on Win/Mac/Linux. --- .../product-domains/src/miniapp/builtin.rs | 2 +- .../builtin/assets/ppt-live/bundle.json | 2 +- .../builtin/assets/ppt-live/dist/ui.bundle.js | 222 +++++++++++-- .../miniapp/builtin/assets/ppt-live/meta.json | 2 +- .../ppt-live/src/pptx-element-export.js | 2 +- .../assets/ppt-live/src/pptx-html-build.js | 308 ++++++++++++++++-- .../test/pptx-ooxml-artifact.test.mjs | 198 ++++++++++- 7 files changed, 681 insertions(+), 55 deletions(-) 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');