feat: complete web-to-pixso JSON mapping - border sides, image assets, orphan nodes, slide container borders
This commit is contained in:
+146
-15
@@ -279,25 +279,38 @@ function convert(input) {
|
|||||||
const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} };
|
const ctx = { canvasWidth, canvasHeight, assets: input.assets || {} };
|
||||||
|
|
||||||
// ===== 直接从 DOM 树中提取每个 slide 的子节点(保留父子关系) =====
|
// ===== 直接从 DOM 树中提取每个 slide 的子节点(保留父子关系) =====
|
||||||
function collectSlideChildren(node, slideX, slideY) {
|
function collectSlideChildren(node, slideX, slideY, parentW) {
|
||||||
if (!node || !node.rect) return [];
|
if (!node || !node.rect) return [];
|
||||||
const results = [];
|
const results = [];
|
||||||
const nr = node.rect;
|
const nr = node.rect;
|
||||||
const rw = nr.width ?? nr.w;
|
const rw = nr.width ?? nr.w;
|
||||||
const rh = nr.height ?? nr.h;
|
const rh = nr.height ?? nr.h;
|
||||||
|
// 保留所有非 raster 节点
|
||||||
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
if (node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
||||||
results.push({
|
results.push({
|
||||||
id: node.id, type: node.type, name: node.name, tag: node.tag,
|
id: node.id, type: node.type, name: node.name, tag: node.tag,
|
||||||
rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh },
|
rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh },
|
||||||
styles: node.styles || {}, src: node.src || node.text || '',
|
styles: node.styles || {}, src: node.src || node.text || '',
|
||||||
layerGroup: node.layerGroup || ''
|
layerGroup: node.layerGroup || '',
|
||||||
|
parentW: parentW || rw
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留 raster 节点作为图片
|
||||||
|
if (node.type === 'RECTANGLE' && node.layerGroup === 'comparison' && node.backgroundImages && node.backgroundImages.length > 0) {
|
||||||
|
results.push({
|
||||||
|
id: node.id, type: 'IMAGE', name: node.name, tag: 'RASTER',
|
||||||
|
rect: { x: nr.x - slideX, y: nr.y - slideY, w: rw, h: rh },
|
||||||
|
styles: node.styles || {}, src: '',
|
||||||
|
layerGroup: node.layerGroup || '',
|
||||||
|
parentW: parentW || rw,
|
||||||
|
backgroundImages: node.backgroundImages
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children && Array.isArray(node.children)) {
|
if (node.children && Array.isArray(node.children)) {
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
results.push(...collectSlideChildren(child, slideX, slideY));
|
results.push(...collectSlideChildren(child, slideX, slideY, rw));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -335,13 +348,62 @@ function convert(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`));
|
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素`));
|
||||||
|
|
||||||
|
// ===== fallback: 收集不在 slide 子树内但坐标在 slide 范围内的节点 =====
|
||||||
|
// 从根节点收集所有非 slide 节点
|
||||||
|
function collectOrphans(node) {
|
||||||
|
if (!node || !node.rect) return [];
|
||||||
|
const results = [];
|
||||||
|
const nr = node.rect;
|
||||||
|
const rw = nr.width ?? nr.w;
|
||||||
|
const rh = nr.height ?? nr.h;
|
||||||
|
// 跳过 slide 容器本身和 raster
|
||||||
|
if (!node.name?.startsWith('slide-') && node.type !== 'RECTANGLE' && node.layerGroup !== 'comparison') {
|
||||||
|
results.push({
|
||||||
|
id: node.id, type: node.type, name: node.name, tag: node.tag,
|
||||||
|
rect: { x: nr.x, y: nr.y, w: rw, h: rh },
|
||||||
|
styles: node.styles || {}, src: node.src || node.text || '',
|
||||||
|
layerGroup: node.layerGroup || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (node.children) for (const c of node.children) {
|
||||||
|
// 跳过 slide 容器的子树(它们已经被 collectSlideChildren 处理了)
|
||||||
|
if (c.name?.startsWith('slide-')) continue;
|
||||||
|
results.push(...collectOrphans(c));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
const orphans = collectOrphans(inputRoot);
|
||||||
|
console.log(`游离节点: ${orphans.length} 个`);
|
||||||
|
|
||||||
|
// 把游离节点按 y 坐标归入最近的 slide
|
||||||
|
for (const or of orphans) {
|
||||||
|
if (or.rect.w == null || or.rect.h == null) continue;
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const sg of slideGroups) {
|
||||||
|
const dist = Math.abs(or.rect.y - sg.slideRect.y);
|
||||||
|
if (dist < sg.slideRect.h && dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = sg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
// 转相对坐标
|
||||||
|
const relNode = { ...or, rect: { ...or.rect } };
|
||||||
|
relNode.rect.x = or.rect.x - best.slideRect.x;
|
||||||
|
relNode.rect.y = or.rect.y - best.slideRect.y;
|
||||||
|
best.children.push(relNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slideGroups.forEach(sg => console.log(` ${sg.name}: ${sg.children.length} 个元素 (含游离)`));
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// 第二步:每个 section → 一个 slide
|
// 第二步:每个 section → 一个 slide
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const slides = [];
|
const slides = [];
|
||||||
|
|
||||||
for (const sg of slideGroups) {
|
for (const sg of slideGroups) {
|
||||||
const objects = [];
|
let objects = [];
|
||||||
let slideBg = null;
|
let slideBg = null;
|
||||||
|
|
||||||
// 从 slide 容器节点取背景色
|
// 从 slide 容器节点取背景色
|
||||||
@@ -357,13 +419,13 @@ function convert(input) {
|
|||||||
console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
|
console.log('处理 ' + sg.name + ': ' + sg.children.length + ' 个节点');
|
||||||
|
|
||||||
for (const n of sg.children) {
|
for (const n of sg.children) {
|
||||||
// 跳过 raster 底图、纯容器
|
// 跳过 raster 底图(保留 IMAGE 类型)
|
||||||
if (n.type === 'RECTANGLE' || n.layerGroup === 'comparison') { continue; }
|
if (n.type === 'RECTANGLE') { continue; }
|
||||||
if (n.tag === 'BODY' || n.tag === 'HTML') { continue; }
|
if (n.tag === 'BODY' || n.tag === 'HTML') { continue; }
|
||||||
|
|
||||||
const r = n.rect;
|
const r = n.rect;
|
||||||
if (r == null || r.w == null || r.h == null) { continue; }
|
if (r == null || r.w == null || r.h == null) { continue; }
|
||||||
const S = 1/96;
|
const S = 1/72;
|
||||||
const opts = {
|
const opts = {
|
||||||
x: Math.round(r.x * S * 1000) / 1000,
|
x: Math.round(r.x * S * 1000) / 1000,
|
||||||
y: Math.round(r.y * S * 1000) / 1000,
|
y: Math.round(r.y * S * 1000) / 1000,
|
||||||
@@ -375,13 +437,18 @@ function convert(input) {
|
|||||||
if (n.type === 'TEXT' && n.src) {
|
if (n.type === 'TEXT' && n.src) {
|
||||||
const st = n.styles;
|
const st = n.styles;
|
||||||
const fs = parseFloat(st.fontSize) || 14;
|
const fs = parseFloat(st.fontSize) || 14;
|
||||||
opts.fontSize = Math.round(fs * 72 / 96);
|
var pt = Math.round(fs);
|
||||||
|
opts.fontSize = pt;
|
||||||
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim();
|
opts.fontFace = (st.fontFamily || 'Arial').split(',')[0].replace(/['"]/g,'').trim();
|
||||||
opts.color = rgbaToHex(st.color) || '000000';
|
opts.color = rgbaToHex(st.color) || '000000';
|
||||||
if (parseInt(st.fontWeight) >= 700) opts.bold = true;
|
if (parseInt(st.fontWeight) >= 700) opts.bold = true;
|
||||||
if (st.fontStyle === 'italic') opts.italic = true;
|
if (st.fontStyle === 'italic') opts.italic = true;
|
||||||
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
|
if (st.textAlign && st.textAlign !== 'start') opts.align = st.textAlign;
|
||||||
|
|
||||||
|
// 宽度优先用父容器,否则用文字自身宽度,再加 20% 余量防止换行
|
||||||
|
var baseW = (n.parentW || r.w);
|
||||||
|
opts.w = Math.round(baseW * 1.2 / 72 * 1000) / 1000;
|
||||||
|
|
||||||
objects.push({
|
objects.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: n.src,
|
text: n.src,
|
||||||
@@ -389,6 +456,21 @@ function convert(input) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 图片节点 =====
|
||||||
|
if (n.type === 'IMAGE' && n.backgroundImages && n.backgroundImages.length > 0) {
|
||||||
|
var imgKey = n.backgroundImages[0];
|
||||||
|
var asset = ctx.assets[imgKey];
|
||||||
|
if (asset && asset.data) {
|
||||||
|
objects.push({
|
||||||
|
type: 'image',
|
||||||
|
options: {
|
||||||
|
x: opts.x, y: opts.y, w: opts.w, h: opts.h,
|
||||||
|
data: asset.data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 提取填充色 =====
|
// ===== 提取填充色 =====
|
||||||
let fillColor = null;
|
let fillColor = null;
|
||||||
const bg = n.styles.backgroundColor;
|
const bg = n.styles.backgroundColor;
|
||||||
@@ -406,8 +488,13 @@ function convert(input) {
|
|||||||
var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } };
|
var shapeOpts = { x: opts.x, y: opts.y, w: opts.w, h: opts.h, fill: { color: fillColor } };
|
||||||
|
|
||||||
// 圆角
|
// 圆角
|
||||||
if (n.styles.borderTopLeftRadius && n.styles.borderTopLeftRadius !== '0px') {
|
var brVal = n.styles.borderTopLeftRadius;
|
||||||
shapeOpts.rectRadius = parseFloat(n.styles.borderTopLeftRadius) / 96;
|
if (brVal && brVal !== '0px') {
|
||||||
|
if (brVal.includes('%')) {
|
||||||
|
shapeOpts.rectRadius = parseFloat(brVal) / 100;
|
||||||
|
} else {
|
||||||
|
shapeOpts.rectRadius = parseFloat(brVal) / 72;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 边框
|
// 边框
|
||||||
@@ -425,10 +512,17 @@ function convert(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
objects.push({ type: 'shape', shapeName: 'rect', options: shapeOpts });
|
// 选择形状类型:圆角 >= 50% 且 w≈h 时用 ellipse(正圆),否则用 roundRect
|
||||||
|
var useShape = 'rect';
|
||||||
|
if (shapeOpts.rectRadius) {
|
||||||
|
var brPct = parseFloat(n.styles.borderTopLeftRadius);
|
||||||
|
var isCircle = (brPct >= 50 || (brPct.toString().includes('%') && parseFloat(brPct) >= 50)) && Math.abs(opts.w - opts.h) < 0.05;
|
||||||
|
useShape = isCircle ? 'ellipse' : 'roundRect';
|
||||||
|
}
|
||||||
|
objects.push({ type: 'shape', shapeName: useShape, options: shapeOpts });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 提取四周边框线(非填充) =====
|
// ===== 提取四周边框线 =====
|
||||||
var sides = [
|
var sides = [
|
||||||
{ key: 'borderTopWidth', yOff: 0, hOff: 0 },
|
{ key: 'borderTopWidth', yOff: 0, hOff: 0 },
|
||||||
{ key: 'borderBottomWidth', yOff: 1, hOff: 0 },
|
{ key: 'borderBottomWidth', yOff: 1, hOff: 0 },
|
||||||
@@ -454,6 +548,38 @@ function convert(input) {
|
|||||||
|
|
||||||
const slideData = { objects };
|
const slideData = { objects };
|
||||||
if (slideBg) slideData.background = slideBg;
|
if (slideBg) slideData.background = slideBg;
|
||||||
|
|
||||||
|
// 提取 slide 容器自身的四周边框
|
||||||
|
if (slideContainer && slideContainer.styles) {
|
||||||
|
var sides = [
|
||||||
|
{ key: 'borderTopWidth', keyC: 'borderTopColor', keyS: 'borderTopStyle', yOff: 0, xOff: 0, isW: false },
|
||||||
|
{ key: 'borderBottomWidth', keyC: 'borderBottomColor', keyS: 'borderBottomStyle', yOff: 1, xOff: 0, isW: false },
|
||||||
|
{ key: 'borderLeftWidth', keyC: 'borderLeftColor', keyS: 'borderLeftStyle', xOff: 0, yOff: 0, isW: true },
|
||||||
|
{ key: 'borderRightWidth', keyC: 'borderRightColor', keyS: 'borderRightStyle', xOff: 1, yOff: 0, isW: true }
|
||||||
|
];
|
||||||
|
for (var si = 0; si < sides.length; si++) {
|
||||||
|
var side = sides[si];
|
||||||
|
var sw = parseFloat(slideContainer.styles[side.key]);
|
||||||
|
var sc = rgbaToHex(slideContainer.styles[side.keyC]);
|
||||||
|
if (sw > 0 && sc && slideContainer.styles[side.keyS] !== 'none') {
|
||||||
|
var sr = sg.slideRect;
|
||||||
|
var lineX = 0, lineY = 0, lineW = 0, lineH = 0;
|
||||||
|
if (side.isW) {
|
||||||
|
lineW = sw / 72;
|
||||||
|
lineH = sr.h / 72;
|
||||||
|
lineX = side.xOff === 0 ? 0 : (sr.w / 72 - sw/72);
|
||||||
|
lineY = 0;
|
||||||
|
} else {
|
||||||
|
lineW = sr.w / 72;
|
||||||
|
lineH = sw / 72;
|
||||||
|
lineX = 0;
|
||||||
|
lineY = side.yOff === 0 ? 0 : (sr.h / 72 - sw/72);
|
||||||
|
}
|
||||||
|
slideData.objects.push({ type: 'shape', shapeName: 'rect', options: { x: lineX, y: lineY, w: lineW, h: lineH, fill: { color: sc } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slides.push(slideData);
|
slides.push(slideData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,8 +591,8 @@ function convert(input) {
|
|||||||
if (slideGroups.length > 0) {
|
if (slideGroups.length > 0) {
|
||||||
const sr = slideGroups[0].slideRect;
|
const sr = slideGroups[0].slideRect;
|
||||||
if (sr && sr.w && sr.h) {
|
if (sr && sr.w && sr.h) {
|
||||||
const sw = sr.w / 96;
|
const sw = sr.w / 72;
|
||||||
const sh = sr.h / 96;
|
const sh = sr.h / 72;
|
||||||
layout = 'CUSTOM';
|
layout = 'CUSTOM';
|
||||||
return {
|
return {
|
||||||
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
|
presentation: { layout: 'CUSTOM', slideWidth: sw, slideHeight: sh },
|
||||||
@@ -549,6 +675,11 @@ if (require.main === module) {
|
|||||||
x: o.x, y: o.y, w: o.w, h: o.h,
|
x: o.x, y: o.y, w: o.w, h: o.h,
|
||||||
fill: o.fill ? { color: o.fill.color } : undefined
|
fill: o.fill ? { color: o.fill.color } : undefined
|
||||||
});
|
});
|
||||||
|
} else if (obj.type === 'image') {
|
||||||
|
slide.addImage({
|
||||||
|
x: o.x, y: o.y, w: o.w, h: o.h,
|
||||||
|
data: o.data || o.path
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user