从网页到可编辑设计稿:浏览器提取与 DSL 生成方案
把一个线上网页变成可编辑设计稿,难点不在截图,而在图层还原。
截图只能保留像素结果。导入 Figma、LTD 网站编辑器这类工具后,文字、图片、按钮、背景和页面分段都不可编辑。要生成设计稿,需要让浏览器先真实加载页面,再读取渲染后的 DOM、样式和坐标,把这些信息转成结构化 DSL。编辑器拿到 DSL 后,再重新创建画布、分段、文本、图片、SVG、背景层和链接。
这套方法的目标很明确:用浏览器拿到真实渲染结果,再把它翻译成可编辑设计稿的数据结构。
方法概览
流程分两层:先把网页变成可采样的渲染现场,再把这个现场翻译成编辑器 DSL。

第一个取舍是,不把网页 DOM 树一比一搬进编辑器。DOM 面向渲染和交互,编辑器 DSL 面向画布和可编辑图层,两者不是同一种结构。
所以它不是“HTML 解析器”,而是“渲染结果采样器”。它不关心一个按钮在 DOM 里嵌了几层 div,关心的是这个按钮在页面上多大、在哪里、有什么文字、背景、边框、圆角和链接,导入编辑器后应该放在哪个 section 里。
为什么不是直接解析 HTML
HTML 源码只描述结构,不等于最终画面。页面真正显示出来之前,还要经过这些环节:
- CSS 计算:继承、优先级、媒体查询、伪元素。
- JS 执行:懒加载、轮播、滚动动画、客户端渲染。
- 资源加载:图片、视频封面、WebFont、SVG symbol。
- 浏览器布局:真实坐标、换行、文本高度、元素裁剪。
只解析 HTML,常见结果是一份“结构看起来完整,但画面还原不了”的数据。这里反过来做:先让浏览器完成渲染,再把渲染结果采样成 DSL。
页面提取的三遍处理
页面内的提取过程采用三遍处理:

第一遍先多拿信息,不急着判断去留。它会把 DOM 元素转成候选图层:
RECTANGLE:有背景、边框、阴影、图片填充的盒子。TEXT:真实文本节点,包含字体、字号、颜色、行高、对齐和约束框。SVG:独立 SVG,包含处理后的 SVG 源码。ICON:由::before、::after伪元素生成的 iconfont 图标。
第二遍理解布局,例如识别页面内容区域、页面分段、父子关系和链接继承。
第三遍清理输出,把图层放进 section,过滤没有视觉意义的空矩形,并把绝对坐标转换成编辑器需要的相对坐标。
三遍处理的价值在于延迟判断。第一遍先保留可能有用的信息;第二遍从页面层面理解结构;第三遍再决定哪些留下、哪些删掉。这样可以避免在局部节点上过早丢信息,后面想恢复页面结构时已经没数据可用。
启发式选择:从网页事实推断设计结构
网页转设计稿不能只靠确定性规则。视觉意图往往不写在标签名里,只能从渲染结果里推断。
启发式判断可以按几条原则做。
第一,优先相信渲染结果,不优先相信源码命名。一个元素是不是 section,不看它是不是 <section> 标签,而看它在视觉上是否形成了页面分段:够不够宽,有没有背景,是否占据一段连续高度,是否承载了一组内容。
第二,优先保留可编辑价值高的内容。文本、图片、SVG、按钮背景、链接关系、文本约束框,通常比空容器更重要。空容器只有承担背景或分段作用时才保留。
第三,允许“设计稿结构不等于原网页结构”。编辑器需要的是便于继续编辑的结构,不是浏览器执行时的 DOM 结构。一个复杂组件在网页里可能嵌了十几层 div,导入编辑器后可以改成一个背景层,加几个文本和图片层。
第四,过滤要能追查。输出里保留原始图层快照,后面才能判断某个元素是被过滤了,还是一开始就没有采样到。
简化后的决策流程如下:
提取前先让页面进入可采样状态
很多现代网页刚打开时还不能提取,常见问题包括:
- 首屏元素还在动画中。
- 滚动后才出现内容。
- 图片没有加载完成。
- Cookie 弹窗、悬浮按钮、客服按钮盖在页面上。
“可采样状态”不能只看页面加载事件。更可靠的判断是下面几项:
- 视口已确定:页面按照目标宽度完成响应式布局。
- 主要资源已就绪:图片、视频封面、字体等不会再大幅改变元素尺寸。
- 动态内容已展开:滚动触发内容、懒加载内容已经出现在 DOM 和布局里。
- 动画不再影响采样:元素不会因为过渡动画处在中间位置。
- 干扰层可识别:弹窗、遮罩、悬浮按钮能被判断出来,而不是混进主体内容。
采样前通常要做三件事:固定视口、触发页面内容、冻结动态效果。
async function makePageSampleable() {
setViewport(targetWidth, targetHeight);
await waitUntilLayoutSettled();
await waitUntilMainImagesReady();
freezeAnimations();
await revealLazyContentByScrolling();
window.scrollTo({ top: 0, behavior: "instant" });
await waitUntilRectsStable();
}
关键不在“等几秒”,而在页面几何信息是否停止明显变化。一个实用做法是连续采样几次页面高度、图片数量、主要元素的 getBoundingClientRect()。连续几次变化都低于阈值,再开始提取。
async function waitUntilRectsStable() {
let previous = null;
let stableCount = 0;
for (let i = 0; i < 10; i++) {
const current = samplePageGeometry();
if (previous && geometryDiff(previous, current) < 2) {
stableCount += 1;
if (stableCount >= 3) return;
} else {
stableCount = 0;
}
previous = current;
await sleep(100);
}
}
滚动预热不是模拟用户浏览,而是让 scroll reveal、懒加载图片、首屏外组件完成初始化。最后回到顶部,保证后续坐标都从同一个页面状态读取。
图层模型:把网页视觉元素翻译成编辑器对象
DSL 不保存 DOM 树,而是保存编辑器能直接消费的图层树。简化后的输出大概是这样:
{
parser_version: "20251201",
root: {
type: "FRAME",
width: 1920,
height: 6400
},
sections: [
{
id: "section-0",
y: 0,
height: 820,
contentX: 240,
contentWidth: 1440,
bodyBackground: { r: 1, g: 1, b: 1, a: 1 },
backgroundLayers: [],
carouselLayers: [],
layers: []
}
],
rootLayers: [],
original_layers: []
}
sections 是导入编辑器后的主结构。每个 section 里再放三类图层:
backgroundLayers:覆盖整个 section 的背景层,坐标相对页面左边缘。layers:普通内容图层,坐标相对内容区域。rootLayers:无法放入 section 的特殊图层。
这样分以后,编辑器不用处理原始 DOM 的复杂嵌套,直接按画布结构重建页面。
文本提取:读取真实渲染,而不是 innerText
文本是网页转设计稿里最容易失真的部分。直接取 innerText 会丢掉真实宽度、行高、换行和文本约束框。
做法是先找出所有 text node,再按最近的 block 容器归组,最后用 Range.getBoundingClientRect() 读取真实渲染位置。
for (const [block, nodes] of textNodesByBlock.entries()) {
const characters = nodes
.map((node) => node.textContent)
.join("")
.trim()
.replace(/\s+/g, " ");
const rects = nodes
.map((node) => {
const range = document.createRange();
range.selectNode(node);
const rect = range.getBoundingClientRect();
range.detach();
return rect;
})
.filter((rect) => rect.width >= 1 && rect.height >= 1);
const textLayer = {
type: "TEXT",
characters,
x: min(rects.map((r) => r.left)),
y: min(rects.map((r) => r.top)),
width: max(rects.map((r) => r.right)) - min(rects.map((r) => r.left)),
height: max(rects.map((r) => r.bottom)) - min(rects.map((r) => r.top)),
fontSize,
fontFamily,
fontWeight,
lineHeight,
fills
};
}
文本层还要保留 constraint。编辑器不仅要知道“文字实际占了多宽”,还要知道“它所在的文本框有多宽”。否则导入后用户只要改一个字,就可能换行错位。
textLayer.constraint = {
x: constraintX,
width: constraintWidth,
height: contentHeight
};
约束框参考 CSS 的 max-width、width、父容器 content box、text-align 等信息计算。
图片、SVG 和 iconfont
图片层主要来自三类来源:
<img>的src。<picture>中匹配当前媒体查询的source。<video>的poster。
判断图片来源时,只看当前视口下浏览器实际选中的那张图。
function selectPictureSource(picture) {
for (const source of picture.querySelectorAll("source")) {
const media = source.getAttribute("media");
if (!media || window.matchMedia(media).matches) {
return source.srcset.split(/[,\s]+/g)[0];
}
}
return picture.querySelector("img")?.src;
}
SVG 也不能简单复制 DOM。很多图标会用 <use href="#symbol-id"> 引用页面里的 symbol。导入编辑器时如果只保留 <use>,目标环境里未必有同名 symbol。提取阶段要把引用展开成真实 SVG 内容。
function inlineSvgUse(svg) {
const clone = svg.cloneNode(true);
for (const use of clone.querySelectorAll("use")) {
const href = use.getAttribute("href") || use.getAttribute("xlink:href");
const referenced = document.querySelector(href);
if (referenced) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.innerHTML = referenced.innerHTML;
use.replaceWith(group);
}
}
return clone.outerHTML;
}
iconfont 要从伪元素读取。很多站点的箭头、搜索、社交图标不在 DOM 文本里,而在 ::before 或 ::after 的 content 中。提取时识别 iconfont 字体,再把它画到 canvas,转成图片数据。
过滤:把干扰元素挡在 DSL 外面
网页里有很多东西存在于 DOM,但不该进入设计稿:
- 透明或缩放为 0 的元素。
- hover 才显示的菜单、tooltip、dropdown。
- 被
overflow: hidden裁掉的内容。 - Cookie 弹窗、全屏遮罩、非顶部悬浮按钮。
- 没有填充、边框、阴影的空矩形。
过滤不能只靠类名,要结合样式和几何信息判断。
function shouldFilterFixedElement(element) {
const style = getComputedStyle(element);
if (style.position !== "fixed") return false;
const rect = element.getBoundingClientRect();
const isAtTop = rect.top <= 100;
const isFullScreen =
rect.width >= viewportWidth * 0.9 &&
rect.height >= viewportHeight * 0.9;
if (isAtTop && !isFullScreen) {
return false; // 顶部导航保留
}
return true; // 遮罩、客服按钮、非顶部悬浮层过滤
}
这条规则很小,但对生成质量影响很大。顶部 fixed 导航通常是页面设计的一部分;右下角客服、Cookie 遮罩、订阅弹窗通常不是设计稿主体。
内容区域:找到页面真正的设计宽度
大多数网页都有一个内容区域,比如 1200px、1280px、1440px;外侧通常是留白或全宽背景。导入编辑器时,如果所有图层都沿用页面绝对坐标,后续编辑会很麻烦。
提取过程会从候选图层里推断 contentX 和 contentWidth:

伪代码如下:
function findMainContentBaseline(layers) {
const candidates = layers.filter((layer) => {
if (layer.width >= viewportWidth * 0.95) return false;
if (layer.width < 30) return false;
if (layer.invisible || layer.isHoverOnly) return false;
if (isFixedOrSticky(layer.ref)) return false;
return true;
});
const regions = splitByY(candidates, { top: 100, normal: 500 });
const boundaries = regions.map((region) => {
const lefts = filterOutliers(region.layers.map((l) => l.x));
const rights = filterOutliers(region.layers.map((l) => l.x + l.width));
return {
left: Math.min(...lefts),
right: Math.max(...rights)
};
});
return symmetricBaseline(boundaries, viewportWidth);
}
这里不追求包含每个元素,而是找出页面反复出现的主内容宽度。全宽背景、轮播溢出图、边缘装饰图,都不能影响这个判断。
分段:把长页面拆成编辑器里的 section
网页是一条连续滚动页面,编辑器更适合按 section 管理。分段逻辑分两层:
精简后的判断如下:
function identifySectionAnchors(layers, contentBaseline) {
const largeContainers = layers.filter((layer) => {
const matchesContent =
near(layer.x, contentBaseline.x, 80) &&
near(layer.width, contentBaseline.width, 120);
const matchesViewport =
layer.x < 50 &&
near(layer.width, viewportWidth, 120);
return (
layer.type === "RECTANGLE" &&
layer.height > 50 &&
hasBackground(layer) &&
(matchesContent || matchesViewport || layer.width > viewportWidth * 0.6)
);
});
const uncoveredRanges = findUncoveredRanges(largeContainers);
const subdivisionAnchors = splitRangesByVerticalGap(uncoveredRanges, layers);
return mergeAndDedupeAnchors([
...largeContainers,
...subdivisionAnchors
]);
}
分段判断不是还原 DOM 里的 section 标签,而是生成编辑器可操作的页面分段。很多网页没有语义化的 <section>,或者一个视觉 section 被很多 div 包起来。对设计稿导入来说,视觉分段比 DOM 标签更重要。
背景采样:section 不只需要图层,也需要底色
section 的背景不一定来自某个明确元素,可能是 body、html、多层半透明容器叠出来的结果。做法是在 section 内部采样,再向上查找覆盖当前区域的容器背景。
function getSectionBackgroundColor(sectionRect) {
const point = {
x: sectionRect.x + sectionRect.width / 2,
y: sectionRect.y + Math.min(100, sectionRect.height / 2)
};
let element = document.elementFromPoint(point.x, point.y);
const colors = [];
while (element) {
if (covers(element, sectionRect)) {
const color = readBackgroundColor(element);
if (color) colors.push(color);
}
element = element.parentElement;
}
return composeAlphaColors(colors);
}
这比直接读取 body 背景更接近真实画面,尤其适合有浅色叠层、渐变块、半透明容器的页面。
坐标转换:从页面坐标改成编辑器坐标
浏览器读到的是页面绝对坐标,编辑器需要的是局部坐标。
转换规则如下:
对应逻辑很直接:
function normalizeCoordinates(sections, contentBaseline) {
for (const section of sections) {
for (const layer of section.backgroundLayers) {
layer.y -= section.y;
}
for (const layer of section.layers) {
layer.x -= contentBaseline.x;
layer.y -= section.y;
if (layer.type === "TEXT" && layer.constraint) {
layer.constraint.x -= contentBaseline.x;
}
}
}
}
坐标体系会直接影响导入后的编辑体验。背景层围绕 section,内容层围绕内容区域,文本框约束跟着内容区域走。后续在编辑器里居中、改宽度、移动 section,数据结构才容易处理。
高保真导入的几个关键点
这套方法的价值不在“列出网页元素”,而在几类面向设计编辑器的转换。
第一,用浏览器作为渲染引擎。页面显示成什么样,就以浏览器计算结果为准。
第二,保留文本约束,不只保留文字像素范围。这样导入后改文案、改字号时,不会立刻变成一堆零散文本。
第三,区分背景层和内容层。全宽背景、section 底色、普通内容使用不同坐标体系,编辑器才能继续按页面结构处理。
第四,过滤干扰层。Cookie 弹窗、hover 菜单、悬浮客服、被裁剪元素如果进入 DSL,会直接破坏设计稿质量。
第五,保留原始图层快照。DSL 里放 original_layers,方便排查过滤规则是否误删,也便于做版本对比。
小结
网页转设计稿不是 HTML 转 JSON,而是把渲染结果翻译成编辑器模型。
浏览器负责得到真实视觉结果;提取过程负责把视觉结果拆成图层、文本、图片、背景和分段;DSL 负责把这些信息组织成编辑器能继续操作的数据。
最后生成的不是一张截图,而是一份接近设计稿语义的结构化描述。只要把“可采样状态”“启发式过滤”“视觉分段”“坐标转换”这几件事想清楚,这套方法就可以服务 LTD 网站编辑器,也可以扩展到 Figma、低代码编辑器、页面复刻工具等场景。
请先 登录后发表评论 ~