@anohanafes/offline-document-viewer
Version:
🔒 完全离线的多格式文档预览器 - 支持PDF、DOCX、PPTX、XLSX、CSV,按需加载,支持URL预览
1,345 lines (1,126 loc) • 65.9 kB
JavaScript
/**
* 基于第三方库的Office文档预览器
* 使用更成熟的解决方案来处理PPTX预览
*/
class OfficeViewer {
constructor() {
this.currentFile = null;
this.slides = [];
}
// 使用Office-Viewer风格的PPTX预览
async renderPPTXWithOfficeViewer(file, container) {
try {
// 创建预览容器
container.innerHTML = `
<div class="office-viewer-content" id="officeViewerContent">
<div class="loading-message">
<div class="loading-spinner"></div>
<p>正在解析PPTX文件...</p>
</div>
</div>
`;
// 尝试使用FileReader读取文件
const arrayBuffer = await file.arrayBuffer();
const blob = new Blob([arrayBuffer], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
});
// 创建临时URL用于嵌入式预览
const blobUrl = URL.createObjectURL(blob);
// 尝试使用不同的预览方法
await this.tryMultiplePreviewMethods(blobUrl, file, container);
} catch (error) {
console.error('Office-Viewer预览失败:', error);
this.showFallbackPreview(container, file);
}
}
async tryMultiplePreviewMethods(blobUrl, file, container) {
const contentDiv = document.getElementById('officeViewerContent');
// 使用组合预览:同时提取图片和文本
try {
await this.tryCombinedPreview(file, contentDiv);
return;
} catch (error) {
console.warn('组合预览失败:', error);
}
// 备用方案1: 仅图片提取
try {
await this.tryImageExtractionPreview(file, contentDiv);
return;
} catch (error) {
console.warn('图片提取预览失败:', error);
}
// 备用方案2: 仅文本提取
try {
await this.tryTextExtractionPreview(file, contentDiv);
return;
} catch (error) {
console.warn('文本提取预览失败:', error);
}
// 最后的备用方案
this.showFallbackPreview(container, file);
}
// 组合预览:同时显示图片和文本,保持原始布局
async tryCombinedPreview(file, container) {
const arrayBuffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
console.log('zip', zip);
// 先提取所有图片
const allImages = await this.extractImages(zip);
// 解析幻灯片关系文件以建立图片映射
const slideRelations = await this.extractSlideRelations(zip);
// 提取幻灯片内容,包括图片和文本的位置关系
const slides = await this.extractSlidesWithMedia(zip, allImages, slideRelations);
// debugger;
this.renderSlidesWithLayout(slides, container);
}
// 提取图片
async extractImages(zip) {
const images = [];
const imagePromises = [];
zip.folder('ppt/media/').forEach((relativePath, file) => {
if (relativePath.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i)) {
const imagePromise = file.async('blob').then(imageData => {
const imageUrl = URL.createObjectURL(imageData);
return {
name: relativePath,
url: imageUrl,
size: imageData.size
};
}).catch(error => {
console.warn('提取图片失败:', relativePath, error);
return null;
});
imagePromises.push(imagePromise);
}
});
const imageResults = await Promise.all(imagePromises);
return imageResults.filter(img => img !== null);
}
// 提取幻灯片关系文件
async extractSlideRelations(zip) {
const relations = {};
const relPromises = [];
zip.folder('ppt/slides/_rels/').forEach((relativePath, file) => {
if (relativePath.endsWith('.xml.rels')) {
const slideNumber = relativePath.match(/slide(\d+)\.xml\.rels/)?.[1];
if (slideNumber) {
const relPromise = file.async('text').then(relsXml => {
relations[slideNumber] = this.parseRelationships(relsXml);
}).catch(error => {
console.warn('解析关系文件失败:', relativePath, error);
});
relPromises.push(relPromise);
}
}
});
// 等待所有关系文件解析完成
await Promise.all(relPromises);
return relations;
}
// 解析关系XML
parseRelationships(xmlContent) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
const relationships = {};
const relElements = xmlDoc.getElementsByTagName('Relationship');
for (let i = 0; i < relElements.length; i++) {
const rel = relElements[i];
const id = rel.getAttribute('Id');
const target = rel.getAttribute('Target');
const type = rel.getAttribute('Type');
if (type && (type.includes('image') || type.includes('Image') || target.match(/\.(jpg|jpeg|png|gif|svg|bmp|webp)$/i))) {
relationships[id] = {
target: target,
type: 'image'
};
}
}
return relationships;
}
// 提取幻灯片内容,包括图片和文本的布局关系
async extractSlidesWithMedia(zip, allImages, slideRelations) {
const slides = [];
const slideFiles = [];
zip.folder('ppt/slides/').forEach((relativePath, file) => {
if (relativePath.endsWith('.xml') && !relativePath.includes('_rels/')) {
slideFiles.push({
path: relativePath,
file: file,
index: parseInt(relativePath.match(/slide(\d+)\.xml/)?.[1] || '0')
});
}
});
slideFiles.sort((a, b) => a.index - b.index);
for (let i = 0; i < slideFiles.length; i++) {
try {
const slideXml = await slideFiles[i].file.async('text');
const slideIndex = slideFiles[i].index;
const slideContent = this.extractSlideContentWithLayout(
slideXml,
slideIndex,
allImages,
slideRelations[slideIndex] || {}
);
slides.push(slideContent);
} catch (error) {
console.warn(`解析幻灯片${i + 1}失败:`, error);
slides.push({
index: i + 1,
title: `幻灯片 ${i + 1}`,
elements: [{type: 'text', content: '解析失败'}]
});
}
}
return slides;
}
// 提取单张幻灯片的完整内容和布局
extractSlideContentWithLayout(xmlContent, slideIndex, allImages, relations) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
const slide = {
index: slideIndex,
title: `幻灯片 ${slideIndex}`,
elements: []
};
// 简化的元素提取逻辑
const spShapes = xmlDoc.getElementsByTagName('p:sp');
const picElements = xmlDoc.getElementsByTagName('p:pic');
const allElements = [];
// 提取文本形状
for (let i = 0; i < spShapes.length; i++) {
const shape = spShapes[i];
// 使用智能文本提取方法,能分离多个文本块
const textBlocks = this.extractShapeTextSmart(shape);
textBlocks.forEach((textBlock, blockIndex) => {
if (textBlock.content.trim()) {
allElements.push({
type: 'text',
content: textBlock.content,
formattedContent: textBlock.formattedContent,
position: textBlock.position,
order: this.getElementOrder(shape) + blockIndex * 0.1,
isTitle: this.isLikelyTitle(textBlock.content)
});
}
});
}
// 提取图片元素
for (let i = 0; i < picElements.length; i++) {
const pic = picElements[i];
const imageInfo = this.extractImageInfo(pic, relations, allImages);
if (imageInfo) {
allElements.push({
type: 'image',
content: imageInfo,
position: imageInfo.position,
order: this.getElementOrder(pic)
});
}
}
allElements.sort((a, b) => a.order - b.order);
// 设置幻灯片标题
const titleElement = allElements.find(el => el.type === 'text' && el.isTitle) ||
allElements.find(el => el.type === 'text');
slide.title = titleElement ? titleElement.content : `幻灯片 ${slideIndex}`;
slide.elements = allElements;
return slide;
}
// 提取形状中的文本段落和位置(支持独立文本框架)
extractShapeTextParagraphs(shapeElement) {
const results = [];
// 查找所有文本框架 (p:txBody)
const txBodyElements = shapeElement.getElementsByTagName('p:txBody');
if (txBodyElements.length > 0) {
// 有文本框架,按段落解析
const txBody = txBodyElements[0];
const paragraphs = txBody.getElementsByTagName('a:p');
for (let i = 0; i < paragraphs.length; i++) {
const paragraph = paragraphs[i];
const textElements = paragraph.getElementsByTagName('a:t');
const texts = [];
for (let j = 0; j < textElements.length; j++) {
const text = textElements[j].textContent.trim();
if (text) {
texts.push(text);
}
}
if (texts.length > 0) {
const basePosition = this.extractElementPosition(shapeElement);
// 为每个段落计算偏移位置
const paragraphPosition = {
...basePosition,
y: basePosition.y + (i * 25), // 简单的垂直偏移
height: Math.max(25, basePosition.height / Math.max(paragraphs.length, 1))
};
const content = texts.join(' ');
results.push({
content: content,
position: paragraphPosition
});
}
}
} else {
// 没有文本框架,使用原有逻辑
const textElements = shapeElement.getElementsByTagName('a:t');
const texts = [];
for (let i = 0; i < textElements.length; i++) {
const text = textElements[i].textContent.trim();
if (text) {
texts.push(text);
}
}
if (texts.length > 0) {
const position = this.extractElementPosition(shapeElement);
const content = texts.join(' ');
results.push({
content: content,
position: position
});
}
}
return results;
}
// 提取独立的文本运行(更细粒度的文本解析)
extractIndividualTextRuns(shapeElement) {
const results = [];
const basePosition = this.extractElementPosition(shapeElement);
// 查找所有文本运行 (a:r)
const textRuns = shapeElement.getElementsByTagName('a:r');
for (let i = 0; i < textRuns.length; i++) {
const run = textRuns[i];
const textElements = run.getElementsByTagName('a:t');
const texts = [];
for (let j = 0; j < textElements.length; j++) {
const text = textElements[j].textContent.trim();
if (text) {
texts.push(text);
}
}
if (texts.length > 0) {
const content = texts.join(' ');
// 尝试从文本运行的属性中获取更精确的位置
// 如果没有特定位置,使用基础位置并添加偏移
const runPosition = {
...basePosition,
x: basePosition.x + (i * Math.max(100, basePosition.width / Math.max(textRuns.length, 1))),
width: Math.max(80, basePosition.width / Math.max(textRuns.length, 1))
};
results.push({
content: content,
position: runPosition
});
}
}
return results;
}
// 智能文本提取方法 - 按段落分离独立文本块,并支持格式
extractShapeTextSmart(shapeElement) {
const results = [];
const basePosition = this.extractElementPosition(shapeElement);
// 首先尝试按段落(a:p)分离
const paragraphs = shapeElement.getElementsByTagName('a:p');
if (paragraphs.length > 1) {
// 多个段落,分别处理,每个段落提取格式
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i];
// 为单个段落提取格式化内容
const formattedContent = this.extractFormattedTextFromParagraph(para);
if (formattedContent.text.trim()) {
// 尝试从段落本身获取位置信息
let paraPosition = this.extractElementPosition(para);
// 如果段落没有独立位置信息,计算段落在文本框内的相对位置
if (paraPosition.x === 50 && paraPosition.y === 50) {
// 获取段落属性来计算垂直位置
const verticalOffset = this.calculateParagraphVerticalOffset(para, i, paragraphs.length, basePosition);
paraPosition = {
...basePosition,
y: basePosition.y + verticalOffset,
height: Math.max(25, basePosition.height / Math.max(paragraphs.length, 1))
};
}
results.push({
content: formattedContent.text,
formattedContent: formattedContent,
position: paraPosition
});
}
}
}
else {
// 单个段落,尝试按文本运行(a:r)分离
const textRuns = shapeElement.getElementsByTagName('a:r');
if (textRuns.length > 1) {
// 多个文本运行,分别处理
for (let i = 0; i < textRuns.length; i++) {
const run = textRuns[i];
// 为单个文本运行提取格式化内容
const formattedContent = this.extractFormattedTextFromRun(run);
if (formattedContent.text.trim()) {
// 尝试从文本运行本身获取位置信息
let runPosition = this.extractElementPosition(run);
// 如果文本运行没有独立位置信息,直接使用形状的基础位置
if (runPosition.x === 50 && runPosition.y === 50) {
runPosition = basePosition;
}
results.push({
content: formattedContent.text,
formattedContent: formattedContent,
position: runPosition
});
}
}
} else {
// 单个文本块,使用完整的格式化提取
const formattedContent = this.extractFormattedTextContent(shapeElement);
if (formattedContent.text.trim()) {
results.push({
content: formattedContent.text,
formattedContent: formattedContent,
position: basePosition
});
}
}
}
return results;
}
// 从单个段落提取格式化文本
extractFormattedTextFromParagraph(paragraph) {
const result = {
text: '',
segments: []
};
// 获取段落样式
const paragraphStyle = this.extractParagraphStyle(paragraph);
// 获取文本运行
const runs = paragraph.getElementsByTagName('a:r');
if (runs.length === 0) {
// 直接提取文本
const textElements = paragraph.getElementsByTagName('a:t');
for (let t = 0; t < textElements.length; t++) {
const textContent = textElements[t].textContent;
if (textContent) {
result.segments.push({
text: textContent,
style: { ...paragraphStyle }
});
result.text += textContent;
}
}
} else {
for (let r = 0; r < runs.length; r++) {
const run = runs[r];
const runStyle = this.extractRunStyle(run);
const textElements = run.getElementsByTagName('a:t');
for (let t = 0; t < textElements.length; t++) {
const textContent = textElements[t].textContent;
if (textContent) {
result.segments.push({
text: textContent,
style: { ...paragraphStyle, ...runStyle }
});
result.text += textContent;
}
}
}
}
return result;
}
// 从单个文本运行提取格式化文本
extractFormattedTextFromRun(run) {
const result = {
text: '',
segments: []
};
const runStyle = this.extractRunStyle(run);
const textElements = run.getElementsByTagName('a:t');
for (let t = 0; t < textElements.length; t++) {
const textContent = textElements[t].textContent;
if (textContent) {
result.segments.push({
text: textContent,
style: { ...runStyle }
});
result.text += textContent;
}
}
return result;
}
// 增强的文本提取方法 - 支持格式信息
extractShapeTextSimple(shapeElement) {
const position = this.extractElementPosition(shapeElement);
// 提取带格式的文本内容
const formattedContent = this.extractFormattedTextContent(shapeElement);
return {
content: formattedContent.text,
formattedContent: formattedContent,
position: position
};
}
// 提取带格式的文本内容
extractFormattedTextContent(shapeElement) {
const result = {
text: '',
segments: [] // 存储每个文本段的内容和格式
};
// 获取所有段落
const paragraphs = shapeElement.getElementsByTagName('a:p');
for (let p = 0; p < paragraphs.length; p++) {
const paragraph = paragraphs[p];
// 获取段落级别的格式
const paragraphStyle = this.extractParagraphStyle(paragraph);
// 获取文本运行 (a:r)
const runs = paragraph.getElementsByTagName('a:r');
if (runs.length === 0) {
// 如果没有文本运行,直接提取文本
const textElements = paragraph.getElementsByTagName('a:t');
for (let t = 0; t < textElements.length; t++) {
const textContent = textElements[t].textContent;
if (textContent) {
result.segments.push({
text: textContent,
style: { ...paragraphStyle }
});
result.text += textContent;
}
}
} else {
for (let r = 0; r < runs.length; r++) {
const run = runs[r];
// 提取文本运行的格式属性
const runStyle = this.extractRunStyle(run);
// 获取文本内容
const textElements = run.getElementsByTagName('a:t');
for (let t = 0; t < textElements.length; t++) {
const textContent = textElements[t].textContent;
if (textContent) {
const combinedStyle = { ...paragraphStyle, ...runStyle };
result.segments.push({
text: textContent,
style: combinedStyle
});
result.text += textContent;
}
}
}
}
// 段落间添加换行(除了最后一个段落)
if (p < paragraphs.length - 1) {
result.text += ' ';
result.segments.push({
text: ' ',
style: { isLineBreak: true }
});
}
}
return result;
}
// 提取段落样式
extractParagraphStyle(paragraph) {
const style = {};
try {
const pPrElements = paragraph.getElementsByTagName('a:pPr');
if (pPrElements.length > 0) {
const pPr = pPrElements[0];
// 对齐方式
const alignment = pPr.getAttribute('algn');
if (alignment) {
switch (alignment) {
case 'l': style.textAlign = 'left'; break;
case 'ctr': style.textAlign = 'center'; break;
case 'r': style.textAlign = 'right'; break;
case 'just': style.textAlign = 'justify'; break;
}
}
// 缩进
const marL = pPr.getAttribute('marL');
if (marL) {
style.marginLeft = Math.round(parseInt(marL) / 914400 * 96) + 'px';
}
}
} catch (error) {
console.warn('提取段落样式失败:', error);
}
return style;
}
// 提取文本运行样式
extractRunStyle(run) {
const style = {};
try {
const rPrElements = run.getElementsByTagName('a:rPr');
if (rPrElements.length > 0) {
const rPr = rPrElements[0];
// 字体大小 (sz属性,单位是半点,需要除以100)
const fontSize = rPr.getAttribute('sz');
if (fontSize) {
style.fontSize = Math.round(parseInt(fontSize) / 100) + 'pt';
}
// 加粗
const bold = rPr.getAttribute('b');
if (bold === '1') {
style.fontWeight = 'bold';
}
// 斜体
const italic = rPr.getAttribute('i');
if (italic === '1') {
style.fontStyle = 'italic';
}
// 下划线
const underline = rPr.getAttribute('u');
if (underline && underline !== 'none') {
style.textDecoration = 'underline';
}
// 删除线
const strike = rPr.getAttribute('strike');
if (strike && strike !== 'noStrike') {
style.textDecoration = (style.textDecoration || '') + ' line-through';
}
// 字体名称
const latinElements = rPr.getElementsByTagName('a:latin');
if (latinElements.length > 0) {
const fontFamily = latinElements[0].getAttribute('typeface');
if (fontFamily) {
style.fontFamily = fontFamily;
}
}
// 中文字体
const eaElements = rPr.getElementsByTagName('a:ea');
if (eaElements.length > 0) {
const eaFontFamily = eaElements[0].getAttribute('typeface');
if (eaFontFamily) {
style.fontFamily = eaFontFamily; // 中文字体优先
}
}
// 字体颜色
const solidFillElements = rPr.getElementsByTagName('a:solidFill');
if (solidFillElements.length > 0) {
const colorElements = solidFillElements[0].getElementsByTagName('a:srgbClr');
if (colorElements.length > 0) {
const colorValue = colorElements[0].getAttribute('val');
if (colorValue) {
style.color = '#' + colorValue;
}
}
// 也检查主题颜色
const schemeClrElements = solidFillElements[0].getElementsByTagName('a:schemeClr');
if (schemeClrElements.length > 0) {
const schemeVal = schemeClrElements[0].getAttribute('val');
// 简单的主题颜色映射
const themeColors = {
'dk1': '#000000',
'lt1': '#ffffff',
'dk2': '#1F497D',
'lt2': '#EEECE1',
'accent1': '#4F81BD',
'accent2': '#F79646',
'accent3': '#9BBB59',
'accent4': '#8064A2',
'accent5': '#4BACC6',
'accent6': '#F24992'
};
if (themeColors[schemeVal]) {
style.color = themeColors[schemeVal];
}
}
}
// 文字背景色/高亮色 (a:highlight)
const highlightElements = rPr.getElementsByTagName('a:highlight');
if (highlightElements.length > 0) {
const highlight = highlightElements[0];
// 检查RGB高亮色
const hlColorElements = highlight.getElementsByTagName('a:srgbClr');
if (hlColorElements.length > 0) {
const hlColorValue = hlColorElements[0].getAttribute('val');
if (hlColorValue) {
style.backgroundColor = '#' + hlColorValue;
}
}
// 检查主题高亮色
const hlSchemeClrElements = highlight.getElementsByTagName('a:schemeClr');
if (hlSchemeClrElements.length > 0) {
const hlSchemeVal = hlSchemeClrElements[0].getAttribute('val');
const themeColors = {
'dk1': '#000000',
'lt1': '#ffffff',
'dk2': '#1F497D',
'lt2': '#EEECE1',
'accent1': '#4F81BD',
'accent2': '#F79646',
'accent3': '#9BBB59',
'accent4': '#8064A2',
'accent5': '#4BACC6',
'accent6': '#F24992',
'hlink': '#0563C1',
'folHlink': '#954F72'
};
if (themeColors[hlSchemeVal]) {
style.backgroundColor = themeColors[hlSchemeVal];
}
}
// 检查预设高亮色
const hlPrstClrElements = highlight.getElementsByTagName('a:prstClr');
if (hlPrstClrElements.length > 0) {
const hlPrstVal = hlPrstClrElements[0].getAttribute('val');
// 常见的预设高亮颜色
const presetColors = {
'yellow': '#FFFF00',
'lime': '#00FF00',
'cyan': '#00FFFF',
'magenta': '#FF00FF',
'blue': '#0000FF',
'red': '#FF0000',
'darkBlue': '#000080',
'darkCyan': '#008080',
'darkGreen': '#008000',
'darkMagenta': '#800080',
'darkRed': '#800000',
'darkYellow': '#808000',
'darkGray': '#808080',
'lightGray': '#C0C0C0',
'black': '#000000'
};
if (presetColors[hlPrstVal]) {
style.backgroundColor = presetColors[hlPrstVal];
}
}
}
// 检查是否有默认文本属性
const defRPrElements = run.parentNode.getElementsByTagName('a:defRPr');
if (defRPrElements.length > 0 && Object.keys(style).length === 0) {
const defRPr = defRPrElements[0];
const defFontSize = defRPr.getAttribute('sz');
if (defFontSize) {
style.fontSize = Math.round(parseInt(defFontSize) / 100) + 'pt';
}
// 检查默认字体
const defLatinElements = defRPr.getElementsByTagName('a:latin');
if (defLatinElements.length > 0) {
const defFontFamily = defLatinElements[0].getAttribute('typeface');
if (defFontFamily) {
style.fontFamily = defFontFamily;
}
}
}
} else {
}
} catch (error) {
console.warn('提取文本运行样式失败:', error);
}
return style;
}
// 计算段落在文本框内的垂直偏移
calculateParagraphVerticalOffset(paragraph, paragraphIndex, totalParagraphs, basePosition) {
let verticalOffset = 0;
try {
// 策略1:根据段落属性计算间距
const pPrElements = paragraph.getElementsByTagName('a:pPr');
if (pPrElements.length > 0) {
const pPr = pPrElements[0];
// 检查段前间距 (a:spcBef)
const spcBefElements = pPr.getElementsByTagName('a:spcBef');
if (spcBefElements.length > 0) {
const spcPts = spcBefElements[0].getElementsByTagName('a:spcPts');
if (spcPts.length > 0) {
const beforeSpacing = parseInt(spcPts[0].getAttribute('val') || 0);
verticalOffset += Math.round(beforeSpacing / 100);
}
}
// 检查行距 (a:lnSpc)
const lnSpcElements = pPr.getElementsByTagName('a:lnSpc');
if (lnSpcElements.length > 0) {
const spcPts = lnSpcElements[0].getElementsByTagName('a:spcPts');
if (spcPts.length > 0) {
const lineSpacing = parseInt(spcPts[0].getAttribute('val') || 0);
if (paragraphIndex > 0) {
verticalOffset += Math.round(lineSpacing / 100 * paragraphIndex);
}
}
}
}
// 策略2:如果没有具体间距信息,使用默认计算
if (verticalOffset === 0 && totalParagraphs > 1) {
// 假设每行约25-30px的高度
const estimatedLineHeight = 30;
verticalOffset = paragraphIndex * estimatedLineHeight;
}
} catch (error) {
console.warn('计算段落垂直偏移失败:', error);
// 回退到简单的等分计算
if (totalParagraphs > 1) {
verticalOffset = (basePosition.height / totalParagraphs) * paragraphIndex;
}
}
return verticalOffset;
}
// 生成格式化的HTML文本
generateFormattedTextHtml(element) {
if (!element.formattedContent || !element.formattedContent.segments) {
return this.escapeHtml(element.content);
}
let html = '';
element.formattedContent.segments.forEach((segment, index) => {
if (segment.style.isLineBreak) {
html += '<br>';
return;
}
const styles = [];
const style = segment.style;
// 应用各种样式
if (style.fontSize) styles.push(`font-size: ${style.fontSize}`);
if (style.fontFamily) styles.push(`font-family: "${style.fontFamily}"`);
if (style.fontWeight) styles.push(`font-weight: ${style.fontWeight}`);
if (style.fontStyle) styles.push(`font-style: ${style.fontStyle}`);
if (style.color) styles.push(`color: ${style.color}`);
if (style.backgroundColor) styles.push(`background-color: ${style.backgroundColor}`);
if (style.textDecoration) styles.push(`text-decoration: ${style.textDecoration}`);
if (style.textAlign) styles.push(`text-align: ${style.textAlign}`);
if (style.marginLeft) styles.push(`margin-left: ${style.marginLeft}`);
const styleAttr = styles.length > 0 ? ` style="${styles.join('; ')}"` : '';
html += `<span${styleAttr}>${this.escapeHtml(segment.text)}</span>`;
});
return html;
}
// 保持原有方法兼容性
extractShapeText(shapeElement) {
return this.extractShapeTextSimple(shapeElement);
}
// 提取图片信息和位置
extractImageInfo(picElement, relations, allImages) {
// 获取图片引用ID
const blipElements = picElement.getElementsByTagName('a:blip');
if (blipElements.length === 0) return null;
const imageRefId = blipElements[0].getAttribute('r:embed');
if (!imageRefId || !relations[imageRefId]) return null;
const imagePath = relations[imageRefId].target;
// 在allImages中查找对应的图片
const matchingImage = allImages.find(img =>
img.name.includes(imagePath.split('/').pop()) ||
imagePath.includes(img.name.split('/').pop())
);
if (matchingImage) {
// 提取位置和尺寸信息
const position = this.extractElementPosition(picElement);
return {
url: matchingImage.url,
name: matchingImage.name,
size: matchingImage.size,
alt: this.extractImageAlt(picElement),
position: position
};
}
return null;
}
// 提取图片替代文本
extractImageAlt(picElement) {
const descElements = picElement.getElementsByTagName('p:cNvPr');
if (descElements.length > 0) {
return descElements[0].getAttribute('descr') ||
descElements[0].getAttribute('name') || '';
}
return '';
}
// 提取元素位置信息 - 增强版,支持多种位置信息来源
extractElementPosition(element) {
try {
// 策略1:查找 p:spPr > a:xfrm (形状属性中的变换)
let xfrmElement = null;
const spPrElements = element.getElementsByTagName('p:spPr');
if (spPrElements.length > 0) {
const xfrmInSpPr = spPrElements[0].getElementsByTagName('a:xfrm');
if (xfrmInSpPr.length > 0) {
xfrmElement = xfrmInSpPr[0];
}
}
// 策略2:查找 p:spPr > a:prstGeom > a:xfrm
if (!xfrmElement && spPrElements.length > 0) {
const prstGeomElements = spPrElements[0].getElementsByTagName('a:prstGeom');
if (prstGeomElements.length > 0) {
const xfrmInGeom = prstGeomElements[0].getElementsByTagName('a:xfrm');
if (xfrmInGeom.length > 0) {
xfrmElement = xfrmInGeom[0];
}
}
}
// 策略3:直接查找 a:xfrm
if (!xfrmElement) {
const xfrmElements = element.getElementsByTagName('a:xfrm');
if (xfrmElements.length > 0) {
xfrmElement = xfrmElements[0];
}
}
if (!xfrmElement) {
return { x: 50, y: 50, width: 200, height: 50 };
}
// 提取偏移量 (a:off)
const offElements = xfrmElement.getElementsByTagName('a:off');
const x = offElements.length > 0 ? parseInt(offElements[0].getAttribute('x') || 0) : 0;
const y = offElements.length > 0 ? parseInt(offElements[0].getAttribute('y') || 0) : 0;
// 提取尺寸 (a:ext)
const extElements = xfrmElement.getElementsByTagName('a:ext');
const width = extElements.length > 0 ? parseInt(extElements[0].getAttribute('cx') || 0) : 0;
const height = extElements.length > 0 ? parseInt(extElements[0].getAttribute('cy') || 0) : 0;
// 将EMU转换为像素 (1 EMU = 1/914400 英寸, 1英寸 = 96像素)
const emuToPixels = (emu) => Math.round(emu / 914400 * 96);
const result = {
x: emuToPixels(x),
y: emuToPixels(y),
width: emuToPixels(width) || 200,
height: emuToPixels(height) || 50
};
return result;
} catch (error) {
console.error('提取位置信息失败:', error);
return { x: 50, y: 50, width: 200, height: 50 };
}
}
// 获取元素在XML中的顺序(简化版)
getElementOrder(element) {
let order = 0;
let current = element;
while (current.previousElementSibling) {
order++;
current = current.previousElementSibling;
}
return order;
}
// 判断是否可能是标题
isLikelyTitle(text) {
if (!text) return false;
return text.length < 100 &&
text.length > 2 &&
!text.includes('。') &&
!text.includes('!')&&
!text.includes('?');
}
// 渲染幻灯片,PowerPoint风格布局
renderSlidesWithLayout(slides, container) {
const totalImages = slides.reduce((count, slide) => {
return count + slide.elements.filter(el => el.type === 'image').length;
}, 0);
this.slides = slides; // 保存slides数据供后续使用
this.currentSlideIndex = 0; // 当前选中的幻灯片
// 计算总页数
const totalSlides = slides.length;
let html = `
<div class="ppt-layout-container">
<!-- 头部工具栏 -->
<div class="ppt-header">
<div class="ppt-toolbar">
<div class="zoom-controls">
<button class="tool-button zoom-out" id="zoomOutButton">🔍-</button>
<span class="zoom-level" id="zoomLevel">100%</span>
<button class="tool-button zoom-in" id="zoomInButton">🔍+</button>
<button class="tool-button fit-width" id="fitWidthButton">适应宽度</button>
</div>
<button class="tool-button fullscreen" id="fullscreenButton">全屏</button>
</div>
</div>
<!-- 主要内容区域 -->
<div class="ppt-main-content">
<!-- 左侧缩略图导航 -->
<div class="ppt-sidebar">
<div class="sidebar-header">幻灯片</div>
<div class="thumbnails-container">
`;
// 生成缩略图列表
slides.forEach((slide, index) => {
const isActive = index === 0 ? 'active' : '';
const hasImage = slide.elements.some(el => el.type === 'image');
html += `
<div class="thumbnail-slide ${isActive}" data-slide-index="${index}">
<div class="thumbnail-number">${index + 1}</div>
<div class="thumbnail-content">
<div class="thumbnail-title">${this.escapeHtml(slide.title)}</div>
<div class="thumbnail-preview">
`;
// 显示缩略图内容预览
if (hasImage) {
const firstImage = slide.elements.find(el => el.type === 'image');
if (firstImage) {
html += `<img src="${firstImage.content.url}" class="thumbnail-image" alt="预览">`;
}
} else {
html += `<div class="thumbnail-text">📝</div>`;
}
html += `
</div>
</div>
</div>
`;
});
html += `
</div>
</div>
<!-- 右侧主预览区域 -->
<div class="ppt-preview-area">
<div class="slide-display-area" id="slideDisplayArea">
`;
// 显示第一张幻灯片的内容
html += this.renderSlideContent(slides[0]);
html += `
</div>
</div>
</div>
</div>
`;
container.innerHTML = html;
// 绑定缩略图点击事件和工具栏按钮
this.bindThumbnailEvents();
// 页码显示已移除
this.bindNavigationButtons();
}
// 渲染单张幻灯片内容 - 使用绝对定位保持PPT布局
renderSlideContent(slide) {
if (slide.elements.length === 0) {
return `<div class="slide-content-display"><div class="display-empty">此幻灯片无可显示内容</div></div>`;
}
// 计算幻灯片边界
const bounds = this.calculateSlideBounds(slide.elements);
let html = `
<div class="slide-content-display">
<div class="slide-navigation">
<button class="nav-button prev-slide" id="prevSlideBtn">← 上一页</button>
<span class="slide-counter" id="slideCounter">第 ${slide.index} 页 / 共 ${this.slides.length} 页</span>
<button class="nav-button next-slide" id="nextSlideBtn">下一页 →</button>
</div>
<div class="slide-canvas" style="
position: relative;
width: ${bounds.width}px;
height: ${bounds.height}px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e1dfdd;
border-radius: 8px;
overflow: hidden;
">
`;
// 按类型分离并渲染元素(图片在底层,文字在上层)
const images = slide.elements.filter(el => el.type === 'image');
const texts = slide.elements.filter(el => el.type === 'text');
// 先渲染图片元素(底层)
images.forEach((element, imageIndex) => {
const pos = element.position;
const imageLeft = pos.x - bounds.minX;
const imageTop = pos.y - bounds.minY;
html += `
<div class="positioned-image-container" style="
position: absolute;
left: ${pos.x - bounds.minX}px;
top: ${pos.y - bounds.minY}px;
width: ${pos.width}px;
height: ${pos.height}px;
z-index: 1;
">
<img src="${element.content.url}"
alt="${this.escapeHtml(element.content.alt)}"
style="
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
">
</div>
`;
});
// 再渲染文字元素(上层) - 支持格式化文本
texts.forEach((element, textIndex) => {
const pos = element.position;
// 跳过已经作为标题显示的文本
if (element.content !== slide.title) {
const cssClass = element.isTitle ? 'positioned-subtitle' : 'positioned-text';
// 使用原始PPT位置,不进行重叠调整
let adjustedX = pos.x - bounds.minX;
let adjustedY = pos.y - bounds.minY;
// 生成格式化的HTML内容
const formattedHtml = this.generateFormattedTextHtml(element);
html += `
<div class="${cssClass}" style="
position: absolute;
left: ${adjustedX}px;
top: ${adjustedY}px;
width: auto;
height: auto;
z-index: 10;
">${formattedHtml}</div>
`;
}
});
html += `
</div>
</div>
`;
return html;
}
// 计算幻灯片内容边界
calculateSlideBounds(elements) {
if (elements.length === 0) {
return { minX: 0, minY: 0, maxX: 800, maxY: 600, width: 800, height: 600 };
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
elements.forEach(element => {
const pos = element.position;
minX = Math.min(minX, pos.x);
minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + pos.width);
maxY = Math.max(maxY, pos.y + pos.height);
});
// 添加边距
const padding = 20;
minX -= padding;
minY -= padding;
maxX += padding;
maxY += padding;
// 确保最小尺寸
const width = Math.max(maxX - minX, 600);
const height = Math.max(maxY - minY, 400);
return { minX, minY, maxX, maxY, width, height };
}
// 绑定缩略图点击事件
bindThumbnailEvents() {
const thumbnails = document.querySelectorAll('.thumbnail-slide');
const slideDisplayArea = document.getElementById('slideDisplayArea');
const slideCounter = document.getElementById('slideCounter');
// 初始化页码显示
if (slideCounter) {
slideCounter.textContent = `第 1 页 / 共 ${this.slides.length} 页`;
}
thumbnails.forEach(thumbnail => {
thumbnail.addEventListener('click', () => {
const slideIndex = parseInt(thumbnail.getAttribute('data-slide-index'));
// 更新活跃状态
thumbnails.forEach(t => t.classList.remove('active'));
thumbnail.classList.add('active');
// 更新右侧显示内容
const selectedSlide = this.slides[slideIndex];
slideDisplayArea.innerHTML = this.renderSlideContent(selectedSlide);
// 更新页码显示
if (slideCounter) {
}
this.currentSlideIndex = slideIndex;
});
});
// 添加键盘导航支持
document.addEventListener('keydown', (e) => {
if ((e.key === 'ArrowUp' || e.key === 'ArrowLeft') && this.currentSlideIndex > 0) {
thumbnails[this.currentSlideIndex - 1].click();
} else if ((e.key === 'ArrowDown' || e.key === 'ArrowRight') && this.currentSlideIndex < this.slides.length - 1) {
thumbnails[this.currentSlideIndex + 1].click();
}
});
}
// 绑定缩放和工具栏按钮事件
bindNavigationButtons() {
const thumbnails = document.querySelectorAll('.thumbnail-slide');
const slideDisplayArea = document.getElementById('slideDisplayArea');
const zoomInButton = document.getElementById('zoomInButton');
const zoomOutButton = document.getElementById('zoomOutButton');
const fitWidthButton = document.getElementById('fitWidthButton');
const fullscreenButton = document.getElementById('fullscreenButton');
const zoomLevelDisplay = document.getElementById('zoomLevel');
// 绑定上一页/下一页按钮事件
const bindSlideNavButtons = () => {
const prevButton = document.getElementById('prevSlideBtn');
const nextButton = document.getElementById('nextSlideBtn');
if (prevButton) {
prevButton.addEventListener('click', () => {
if (this.currentSlideIndex > 0) {
const prevIndex = this.currentSlideI