从网页到可编辑设计稿:浏览器提取与 DSL 生成方案

老蒋2026-07-07 14:19:22

把一个线上网页变成可编辑设计稿,难点不在截图,而在图层还原。

截图只能保留像素结果。导入 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-widthwidth、父容器 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::aftercontent 中。提取时识别 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;外侧通常是留白或全宽背景。导入编辑器时,如果所有图层都沿用页面绝对坐标,后续编辑会很麻烦。

提取过程会从候选图层里推断 contentXcontentWidth

伪代码如下:

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、低代码编辑器、页面复刻工具等场景。

曝光3722浏览302