feat: TEXT 节点合并 — 同 y 坐标的内联文本合并为富文本
- 同 y(误差<5px)且 x 相邻或重叠的 TEXT 节点合并 - 合并后保留 bold/italic/color 等格式差异 - 用 pptxgenjs 原生富文本支持渲染 - 16个合并对象,不影响其他独立文本框
This commit is contained in:
+76
-5
@@ -507,6 +507,64 @@ function convert(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TEXT 节点合并:同 y 坐标且 x 相邻的内联文本合并为富文本 =====
|
||||
const textNodes = sg.children.filter(n => n.type === 'TEXT' && n.src);
|
||||
const merged = new Set(); // 已合并的节点 id
|
||||
for (let i = 0; i < textNodes.length; i++) {
|
||||
if (merged.has(textNodes[i].id)) continue;
|
||||
const a = textNodes[i];
|
||||
const ra = a.rect;
|
||||
// 找同 y 坐标(误差<5px)且 x 相邻(误差<5px)的节点
|
||||
const group = [a];
|
||||
for (let j = i + 1; j < textNodes.length; j++) {
|
||||
if (merged.has(textNodes[j].id)) continue;
|
||||
const b = textNodes[j];
|
||||
const rb = b.rect;
|
||||
if (Math.abs(ra.y - rb.y) > 5) continue;
|
||||
// 检查 x 相邻或重叠:a 的右边缘 ≈ b 的左边缘,或 x 坐标接近(内联元素嵌套)
|
||||
const aRight = ra.x + ra.w;
|
||||
const bRight = rb.x + rb.w;
|
||||
const xAdjacent = Math.abs(aRight - rb.x) < 5 || Math.abs(bRight - ra.x) < 5;
|
||||
const xOverlap = Math.abs(ra.x - rb.x) < 5; // 同 x 起始位置(strong 等内联元素)
|
||||
if (xAdjacent || xOverlap) {
|
||||
group.push(b);
|
||||
merged.add(b.id);
|
||||
}
|
||||
}
|
||||
if (group.length > 1) {
|
||||
merged.add(a.id);
|
||||
// 按 x 排序
|
||||
group.sort((m, n) => m.rect.x - n.rect.x);
|
||||
// 创建富文本对象
|
||||
const minX = group[0].rect.x;
|
||||
const maxRight = Math.max(...group.map(g => g.rect.x + g.rect.w));
|
||||
const minTop = Math.min(...group.map(g => g.rect.y));
|
||||
const maxBottom = Math.max(...group.map(g => g.rect.y + g.rect.h));
|
||||
const richText = group.map(g => ({
|
||||
text: g.src,
|
||||
options: {
|
||||
fontSize: Math.round(parseFloat(g.styles.fontSize) || 14),
|
||||
fontFace: (g.styles.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim(),
|
||||
color: rgbaToHex(g.styles.color) || '000000',
|
||||
bold: parseInt(g.styles.fontWeight) >= 700,
|
||||
italic: g.styles.fontStyle === 'italic'
|
||||
}
|
||||
}));
|
||||
sg.children.push({
|
||||
id: 'merged-' + a.id,
|
||||
type: 'TEXT',
|
||||
_richText: richText,
|
||||
rect: { x: minX, y: minTop, w: maxRight - minX, h: maxBottom - minTop },
|
||||
styles: a.styles || {},
|
||||
src: group.map(g => g.src).join(''),
|
||||
parentW: a.parentW,
|
||||
_merged: true
|
||||
});
|
||||
}
|
||||
}
|
||||
// 移除已合并的原始节点
|
||||
sg.children = sg.children.filter(n => !merged.has(n.id));
|
||||
|
||||
for (const n of sg.children) {
|
||||
// 跳过 raster 底图(保留 IMAGE 类型)
|
||||
if (n.type === 'RECTANGLE') { continue; }
|
||||
@@ -541,10 +599,10 @@ function convert(input) {
|
||||
if (st.textDecorationLine.includes('underline')) opts.underline = true;
|
||||
if (st.textDecorationLine.includes('line-through')) opts.strike = 'sngStrike';
|
||||
}
|
||||
// 字符间距
|
||||
// 字符间距(CSS letterSpacing px → pptxgenjs charSpacing,单位 pt)
|
||||
if (st.letterSpacing && st.letterSpacing !== 'normal') {
|
||||
var ls = parseFloat(st.letterSpacing);
|
||||
if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 100 / fs); // 转为百分比
|
||||
if (!isNaN(ls) && ls !== 0) opts.charSpacing = Math.round(ls * 72 / 96); // 96dpi px → 72dpi pt
|
||||
}
|
||||
// 透明度
|
||||
if (st.opacity !== undefined && st.opacity !== '' && parseFloat(st.opacity) < 1) {
|
||||
@@ -564,6 +622,11 @@ function convert(input) {
|
||||
if (textW > contentW * 0.8) textW = contentW;
|
||||
if (textW > contentW) textW = contentW;
|
||||
if (textW < 108) textW = 108; // 最小 1.5in = 108px
|
||||
// 补偿 PPT 文本框内部 padding(约0.2in = 14px)
|
||||
textW = textW + 14;
|
||||
// 如果估算的文字宽度超过文本框,扩展文本框
|
||||
var estTextW = n.src.length * pt * 0.65; // 粗略估算
|
||||
if (estTextW > textW && estTextW < contentW) textW = Math.ceil(estTextW);
|
||||
// 确保 x + w 不超出 slide 内容区右边界
|
||||
var maxW = contentW - r.x;
|
||||
if (maxW < 108) maxW = 108;
|
||||
@@ -572,7 +635,7 @@ function convert(input) {
|
||||
|
||||
objects.push({
|
||||
type: 'text',
|
||||
text: n.src,
|
||||
text: n._richText || n.src, // 富文本数组或普通字符串
|
||||
options: opts
|
||||
});
|
||||
continue; // TEXT 节点不参与后续的填充色/边框逻辑
|
||||
@@ -626,12 +689,20 @@ function convert(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// 边框:只在四边都有 border 时加 shape 的 line,单边由后续"四周边框线"逻辑处理
|
||||
// 边框:只在四边颜色和宽度一致时加 shape 的 line,否则由"四周边框线"逻辑处理
|
||||
var bw = parseFloat(n.styles.borderTopWidth);
|
||||
var bbw = parseFloat(n.styles.borderBottomWidth);
|
||||
var blw = parseFloat(n.styles.borderLeftWidth);
|
||||
var brw = parseFloat(n.styles.borderRightWidth);
|
||||
if (bw > 0 && bbw > 0 && blw > 0 && brw > 0 && n.styles.borderTopStyle !== 'none') {
|
||||
var bc = rgbaToHex(n.styles.borderTopColor);
|
||||
var bbc = rgbaToHex(n.styles.borderBottomColor);
|
||||
var blc = rgbaToHex(n.styles.borderLeftColor);
|
||||
var brc = rgbaToHex(n.styles.borderRightColor);
|
||||
var allSameBorder = bw > 0 && bbw > 0 && blw > 0 && brw > 0
|
||||
&& n.styles.borderTopStyle !== 'none'
|
||||
&& bw === bbw && bw === blw && bw === brw
|
||||
&& bc && bc === bbc && bc === blc && bc === brc;
|
||||
if (allSameBorder) {
|
||||
var bc = rgbaToHex(n.styles.borderTopColor);
|
||||
if (bc) shapeOpts.line = { color: bc, width: bw };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user