• 博客园logo
  • 会员
  • 周边
  • 新闻
  • 博问
  • 闪存
  • 赞助商
  • Chat2DB
    • 搜索
      所有博客
    • 搜索
      当前博客
  • 写随笔 我的博客 短消息 简洁模式
    用户头像
    我的博客 我的园子 账号设置 会员中心 简洁模式 ... 退出登录
    注册 登录
ZiWeiter
博客园    首页    新随笔    联系   管理    订阅  订阅

UniAPP-通过节点信息自动绘制海报

    使用背景:直接使用节点渲染的某个弹窗海报,没有找到相关的控件,所以手动写了一个。

   这个是node2canvas基础

UniNode2Canvas
// utils/plugins/node2canvas/index.ts

// UniApp 中通常不需要引入特定的 runtime 类型,直接使用 uni 全局对象
// 如果需要在 TS 中获得更好的提示,可以安装 @dcloudio/types

type Position = 'static' | 'absolute' | 'fixed';

interface ElementStyles {
  // 唯一标识,用于关联父子关系或图片源等,如果原始数据中没有,调用方需确保数据中包含必要信息
  id?: string; 
  // 如果是图片节点,可能需要额外存储 src,因为 computedStyle 中通常不包含 src
  src?: string; 
  // 如果是文本节点,存储文本内容
  text?: string;
  // 节点类型标识: 'view', 'text', 'image' 等,用于判断绘制逻辑
  type?: 'view' | 'text' | 'image';
  
  position: Position;
  left: number;
  top: number;
  width: string;
  height: string;
  color: string;
  font: string;
  fontFamily: string;
  fontSize: string;
  fontStyle: string;
  fontWeight: string;
  lineHeight: string;
  textAlign: CanvasTextAlign;
  verticalAlign: CanvasTextBaseline;
  background: string;
  backgroundImage: string;
  backgroundSize: string;     // 确保存在
  backgroundPosition: string; // 确保存在
  backgroundRepeat: string;   // 新增
  backgroundColor: string;
  backgroundClip: string;
  padding: string;
  paddingTop: string;
  paddingRight: string;
  paddingBottom: string;
  paddingLeft: string;
  margin: string;
  marginTop: string;
  marginRight: string;
  marginBottom: string;
  marginLeft: string;
  border: string;
  borderWidth: string;
  borderColor: string;
  borderStyle: string;
  borderRadius: string;
  textDecoration: string;
  opacity: number;
  zIndex: number;
  boxShadow: string;
  boxSizing: string;
  
  // 可选:如果有子节点,可以嵌套,或者保持扁平由调用方排序
  children?: ElementStyles[];
}

/**
 * TextRenderConfig
 */
type TextRenderConfig = {
  x: number;
  y: number;
  text: string;
  width?: number;
  textWidth: number;
  fontSize: number;
  textDecoration?: string;
}

/**
 * UniNode2CanvasOptions
 */
export interface UniNode2CanvasOptions {
  /** 必传: Canvas 实例 (在 UniApp H5/APP/小程序中获取方式不同,通常通过 canvas-id 或 type="2d" 获取) */
  canvas: any; // UniApp 中 Canvas 实例类型可能因平台而异,这里用 any 兼容,建议根据实际平台定义
  /** 必传: 已经获取好样式的节点数据数组 */
  nodes: ElementStyles[];
  /** 当前组件实例 (可选,用于某些特定上下文) */
  component?: any;
  /** 绘制完成后回调 */
  onDone?: (this: UniNode2Canvas) => void;
  /** 绘制进度监听 */
  onProgress?: (_this: UniNode2Canvas, progress: number) => void;
}

export default class UniNode2Canvas {
  private ctx: CanvasRenderingContext2D;
  private canvas: any;
  private nodes: ElementStyles[];
  private component?: any;
  private rootRect?: ElementStyles;
  private onDone?: (_this: UniNode2Canvas) => void;
  private onProgress?: (_this: UniNode2Canvas, progress: number) => void;
  
  /**
   * 设备DPR
   */
  private dpr: number = 1;
  
  /**
   * 绘制状态
   */
  private status: 'pending' | 'running' | 'done' = 'pending';
  
  /**
   * 总的绘制节点数量
   */
  private nodeTotalCount: number = 0;
  
  /**
   * 当前的绘制节点数量
   */
  private _nodeCurrentCount: number = 0;

  get nodeCurrentCount(): number {
    return this._nodeCurrentCount;
  }

  set nodeCurrentCount(value: number) {
    this._nodeCurrentCount = value;
    // 防止除以0
    const total = this.nodeTotalCount || 1;
    this.progress = this._nodeCurrentCount / total;

    if (typeof this.onProgress === 'function') this.onProgress(this, this.progress);
    
    if (this.nodeCurrentCount >= this.nodeTotalCount) {
      this.status = 'done';
      this.onDone && this.onDone(this);
    }
  }
  
  private progress: number = 0;

  /**
   * 初始化操作
   * @param options UniNode2CanvasOptions
   */
  constructor(options: UniNode2CanvasOptions) {
    if (!options.canvas || !options.nodes) throw new Error('canvas and nodes are required');
    
    this.canvas = options.canvas;
    this.nodes = options.nodes;
    this.component = options.component;
    
    // 在 UniApp 中,获取 Context 的方式可能略有不同,特别是小程序端
    // 假设传入的 canvas 是已经初始化好的 Canvas 实例 (如 type="2d")
    this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
    
    this.onDone = options.onDone;
    this.onProgress = options.onProgress;
    
    // 初始化
    this.initCanvasBoard();
  }

  /**
   * 初始化画布板
   * @description 获取设备DPR并设置画布尺寸
   */
  initCanvasBoard() {
    // UniApp 获取系统信息
    const sysInfo = uni.getSystemInfoSync();
    this.dpr = sysInfo.pixelRatio || 1;

    // 寻找根节点(通常是数组第一个,或者具有特定标识的节点)
    // 假设 nodes[0] 是容器根节点,或者我们需要遍历找到最大的那个作为根
    if (this.nodes.length > 0) {
      // 简单策略:取第一个节点作为参考,或者取面积最大的
      // 这里假设调用方传入的 nodes 顺序正确,或者第一个是根容器
      const root = this.nodes[0];
      
      // 设置画布实际像素大小
      this.canvas.width = this.format(this.getValueByUnit(root.width));
      this.canvas.height = this.format(this.getValueByUnit(root.height));
      
      this.rootRect = root;
      this.nodeTotalCount = this.nodes.length;
    }
  }

  /**
   * 开始绘制
   */
  async draw() {
    this.status = 'running';
    this.nodeCurrentCount = 0; // 重置计数

    // 清空画布
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    // 遍历节点进行绘制
    // 注意:如果 nodes 是扁平数组,需要确保绘制顺序(z-index 或 DOM 顺序)
    // 如果 nodes 包含 children 树形结构,则需要递归
    // 这里假设传入的是扁平化且已排序好的数组,或者简单的树形结构
    
    for (const node of this.nodes) {
      await this.renderNode(node);
    }
  }

  /**
   * 渲染单个节点
   */
  async renderNode(rect: ElementStyles) {
    // 1. 渲染背景
    await this.renderBackgroundNode(rect);
    
    // 2. 渲染边框
    this.renderBorderNode(rect);

    // 3. 根据类型渲染内容
    if (rect.type === 'image') {
      await this.drawImage(rect);
    } else if (rect.type === 'text' || rect.text) {
      this.drawText(rect);
    } else if (rect.children && rect.children.length > 0) {
      // 如果有子节点,递归渲染
      // 注意:子节点的坐标通常是相对于父节点的,或者是绝对坐标。
      // 原代码中 getRootRectRelationPosition 是基于绝对坐标计算的。
      // 如果传入的数据已经是绝对坐标(left/top 相对于页面或容器),则直接绘制即可。
      // 这里假设传入的 left/top 已经是相对于 Canvas 根容器的绝对坐标。
      for (const child of rect.children) {
        await this.renderNode(child);
      }
    }

    // 更新进度
    this.nodeCurrentCount++;
  }

  /**
   * 绘制圆角矩形辅助函数
   * @description 与原生 roundRect 保持一致,会将每个角的半径钳制为不超过 `min(width, height) / 2`,
   *              避免圆角半径大于宽高一半时圆弧中心落在矩形外、导致裁剪路径错乱的问题。
   */
  drawRoundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number | number[]) {
    const radii = Array.isArray(radius) ? radius : [radius, radius, radius, radius];
    // CSS 规范:任意圆角半径最大不超过短边的一半,否则按 min(width, height) / 2 钳制
    const maxRadius = Math.max(0, Math.min(width, height) / 2);
    const [r1, r2, r3, r4] = radii.map(r => Math.min(Math.max(0, r), maxRadius));

    ctx.beginPath();
    ctx.arc(x + r1, y + r1, r1, Math.PI, Math.PI * 1.5);
    ctx.lineTo(x + width - r2, y);
    ctx.arc(x + width - r2, y + r2, r2, Math.PI * 1.5, Math.PI * 2);
    ctx.lineTo(x + width, y + height - r3);
    ctx.arc(x + width - r3, y + height - r3, r3, 0, Math.PI * 0.5);
    ctx.lineTo(x + r4, y + height);
    ctx.arc(x + r4, y + height - r4, r4, Math.PI * 0.5, Math.PI);
    ctx.closePath();
  }

  /**
   * 创建圆角矩形路径(自动 beginPath)
   * @description 原生 roundRect 与 rect 一样不会隐式清空当前路径,只会向当前路径追加子路径。
   *              若不在每次调用前 beginPath,多个节点的路径会跨节点累积,导致后续节点 clip() 时
   *              裁剪区域被前序节点的形状污染(典型表现:重叠的圆角节点只有第一个生效)。
   * @param x 左上角 X(物理像素,已 DPR 缩放)
   * @param y 左上角 Y(物理像素,已 DPR 缩放)
   * @param w 宽度(物理像素,已 DPR 缩放)
   * @param h 高度(物理像素,已 DPR 缩放)
   * @param radii 四角半径数组 [左上, 右上, 右下, 左下](物理像素,已 DPR 缩放)
   */
  beginRoundRectPath(x: number, y: number, w: number, h: number, radii: number[]) {
    this.ctx.beginPath();
    if (typeof this.ctx.roundRect === 'function') {
      this.ctx.roundRect(x, y, w, h, radii);
    } else {
      this.drawRoundRect(this.ctx, x, y, w, h, radii);
    }
  }
  /**
   * 将 rgb/rgba 颜色字符串转换为十六进制格式,以兼容小程序 Canvas addColorStop
   * @param colorStr 颜色字符串,如 'rgb(255, 65, 117)' 或 'rgba(255, 65, 117, 0.5)'
   * @returns 十六进制颜色字符串,如 '#FF4175' 或 '#FF417580'
   */
  private formatColorToHex(colorStr: string): string {
    const str = colorStr.trim();
    // 匹配 rgb 或 rgba
    const rgbaMatch = str.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([01]?\.?\d*)\s*)?\)$/i);
    
    if (rgbaMatch) {
      const r = parseInt(rgbaMatch[1], 10);
      const g = parseInt(rgbaMatch[2], 10);
      const b = parseInt(rgbaMatch[3], 10);
      const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;

      const toHex = (num: number) => Math.min(255, Math.max(0, num)).toString(16).padStart(2, '0');
      
      const hexColor = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
      
      // 如果存在透明度且小于 1,则拼接 alpha 的两位十六进制
      if (a < 1) {
        const alphaHex = Math.round(a * 255).toString(16).padStart(2, '0');
        return `${hexColor}${alphaHex}`;
      }
      
      return hexColor;
    }
    
    // 如果不是 rgb 格式(可能本身就是 hex),直接返回
    return str;
  }

  /**
   * 绘制渐变色背景
   */
  async drawGradient(
    rect: any, 
    x: number, 
    y: number, 
    width: number, 
    height: number, 
    gradientStr: string
  ): Promise<void> {
    const ctx = this.ctx;
    
    // 1. 解析渐变字符串
    const gradientData = this.parseGradient(gradientStr);
    if (!gradientData || gradientData.colorStops.length === 0) return;

    // 2. 计算渐变起止坐标(逻辑坐标)
    const { angle, colorStops } = gradientData;
    const radians = (angle - 90) * (Math.PI / 180); // CSS角度转数学弧度 (CSS中0deg是从下到上)
    
    // 计算渐变线在矩形上的交点(确保渐变覆盖整个矩形)
    const halfDiagonal = Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians));
    const centerX = x + width / 2;
    const centerY = y + height / 2;
    
    const x1 = centerX - (Math.cos(radians) * halfDiagonal) / 2;
    const y1 = centerY - (Math.sin(radians) * halfDiagonal) / 2;
    const x2 = centerX + (Math.cos(radians) * halfDiagonal) / 2;
    const y2 = centerY + (Math.sin(radians) * halfDiagonal) / 2;

    // 3. 创建 Canvas 线性渐变
    // 注意:ctx 的坐标空间是物理像素(canvas 尺寸已按 DPR 缩放),
    // 这里所有坐标必须 format(),否则高 DPR 下渐变只绘制在左上角一小块,表现为"渐变没生效"
    const gradient = ctx.createLinearGradient(this.format(x1), this.format(y1), this.format(x2), this.format(y2));

    // 4. 添加颜色断点 (此时 color 已经由 parseGradient 转换为 Hex 格式)
    colorStops.forEach((stop: { offset: number; color: string }) => {
      gradient.addColorStop(stop.offset, stop.color); // color已经被formatColorToHex统一转为合法的Hex格式
    });

    // 5. 绘制(物理像素)
    ctx.save();
    ctx.fillStyle = gradient;
    ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
    ctx.restore();
  }

  /**
   * 解析 CSS 渐变字符串
   */
  parseGradient(gradientStr: string): { angle: number; colorStops: { offset: number; color: string }[] } | null {
    const match = gradientStr.match(/(linear|radial)-gradient\(([\s\S]+)$/);
    if (!match) return null;

    // 注意:rgba 里面包含逗号,直接 split(',') 会把 rgba 拆坏
    // 这里使用更严谨的正则,直接提取 deg 和 颜色节点
    const innerStr = match[2];
    
    let angle = 180; // 默认从上到下
    const colorStops: { offset: number; color: string }[] = [];

    // 提取角度
    const degMatch = innerStr.match(/^(\d+\.?\d*)deg/);
    if (degMatch) {
      angle = parseFloat(degMatch[1]);
    }

    // 提取颜色和偏移量 (兼容 rgb, rgba, hex)
    // 匹配模式:颜色 + 可选的百分比
    const colorReg = /(rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*[01]?\.?\d*\s*)?\)|#[0-9a-fA-F]{3,8})\s*([0-9]*\.?[0-9]+%)?/g;
    
    let colorMatch;
    while ((colorMatch = colorReg.exec(innerStr)) !== null) {
      const rawColor = colorMatch[1];
      const rawOffset = colorMatch[2];
      
      // 核心:在这里将 rgb 转换为 Hex
      const hexColor = this.formatColorToHex(rawColor);
      const offset = rawOffset ? parseFloat(rawOffset) / 100 : -1; // -1 表示未指定偏移量
      
      colorStops.push({ offset, color: hexColor });
    }

    // 处理未指定偏移量的颜色节点,均匀分配位置
    if (colorStops.length > 0) {
      let lastExplicitOffset = -1;
      for (let i = 0; i < colorStops.length; i++) {
        if (colorStops[i].offset === -1) {
          // 找到下一个有明确偏移量的节点
          let nextExplicitOffset = 1;
          for (let j = i + 1; j < colorStops.length; j++) {
            if (colorStops[j].offset !== -1) {
              nextExplicitOffset = colorStops[j].offset;
              break;
            }
          }
          
          // 计算中间有多少个未知节点
          let unknownCount = 0;
          for (let k = i; k < colorStops.length; k++) {
            if (colorStops[k].offset === -1) unknownCount++;
            else break;
          }
          
          const step = (nextExplicitOffset - (lastExplicitOffset !== -1 ? lastExplicitOffset : 0)) / (unknownCount + 1);
          colorStops[i].offset = (lastExplicitOffset !== -1 ? lastExplicitOffset : 0) + step * (unknownCount > 1 ? 1 : 1); // 简化步长计算
        }
        lastExplicitOffset = colorStops[i].offset;
      }
      
      // 兜底:如果第一个不是0,最后一个不是1,强行修正
      if (colorStops[0].offset > 0) colorStops[0].offset = 0;
      if (colorStops[colorStops.length - 1].offset < 1) colorStops[colorStops.length - 1].offset = 1;
    }

    return { angle, colorStops };
  }

  /**
   * 统一绘制背景内容(自动区分渐变与背景图)
   * @description 供 renderBackgroundNode 在圆角裁剪区域内外统一调用,
   *              避免无圆角分支/降级兜底分支遗漏渐变背景,导致渐变背景不生效。
   */
  private async drawBackgroundContent(rect: ElementStyles, x: number, y: number, width: number, height: number) {
    const bgValue = rect.backgroundImage.trim();
    
    // 判断是否为渐变色
    if (bgValue.startsWith('linear-gradient') || bgValue.startsWith('radial-gradient')) {
      await this.drawGradient(rect, x, y, width, height, bgValue);
    } else {
      // 否则按普通背景图片处理
      await this.drawBackgroundImage(rect, x, y, width, height);
    }
  }



  /**
   * 渲染背景
   */
  async renderBackgroundNode(rect: ElementStyles) {
    if (rect.type === 'text') return; // 文本节点通常不单独绘制背景块,除非有特定需求

    let width = this.getValueByUnit(rect.width);
    let height = this.getValueByUnit(rect.height);

    /// 这里需要针对width和height做处理 boxSizing 模式 如果是content-box需要加上padding距离 如果是border-box则使用默认宽度
    if (rect.boxSizing === 'content-box') {
      // width += Number(rect.paddingLeft) + Number(rect.paddingRight);
      // height += Number(rect.paddingTop) + Number(rect.paddingBottom);
      const { top, right, bottom, left } = this.getPadding(rect);
      /// 开始累加宽度到绘制宽度中
      if (top.unit == 'px') {
        height += top.value;
      }
      if (bottom.unit == 'px') {
        height += bottom.value
      }
      if (left.unit == 'px') {
        width += left.value;
      }
      if (right.unit == 'px') {
        width += right.value
      }
      // 结束累计
    }
    
    if (width <= 0 || height <= 0) return;

    const { x, y } = this.getPosition(rect);
    const radii = this.getBorderRadius(rect);
    
    // 判断是否有有效的圆角 (数组非空且至少有一个值大于0)
    const hasRadius = radii.length > 0 && radii.some(r => r > 0);

    if (hasRadius) {
      this.ctx.save();
      try {
        // 创建圆角裁剪路径
        // 注意:roundRect 的半径参数也需要 format 以适配 DPR
        const formattedRadii = radii.map(r => this.format(r));
        
        this.beginRoundRectPath(this.format(x), this.format(y), this.format(width), this.format(height), formattedRadii);
        this.ctx.clip();
        // 清空当前路径,防止跨节点累积导致后续 clip 区域被污染
        this.ctx.beginPath();
        
        // 在裁剪区域内绘制背景色
        if (rect.backgroundColor) {
          this.ctx.fillStyle = rect.backgroundColor;
          this.ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
        }

        // 在裁剪区域内绘制背景(自动区分渐变与背景图)
        if (rect.backgroundImage && rect.backgroundImage !== 'none') {
          await this.drawBackgroundContent(rect, x, y, width, height);
        }
      } catch (e) {
        // 兼容性兜底:roundRect/clip 失败时降级为不带圆角绘制,避免背景丢失
        console.warn('背景圆角裁剪失败,已降级为直接绘制:', e);
        if (rect.backgroundColor) {
          this.ctx.fillStyle = rect.backgroundColor;
          this.ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
        }
        if (rect.backgroundImage && rect.backgroundImage !== 'none') {
          await this.drawBackgroundContent(rect, x, y, width, height);
        }
      } finally {
        // 无论如何都要恢复 ctx 状态,防止影响后续节点绘制
        this.ctx.restore();
      }
    } else {
      // --- 修复点:没有圆角时,直接使用 fillRect,不要调用 roundRect ---
      if (rect.backgroundColor) {
        this.ctx.fillStyle = rect.backgroundColor;
        this.ctx.fillRect(this.format(x), this.format(y), this.format(width), this.format(height));
      }

      if (rect.backgroundImage && rect.backgroundImage !== 'none') {
        await this.drawBackgroundContent(rect, x, y, width, height);
      }
    }
  }

  /**
   * 解析 background-size 属性
   * @param sizeStr background-size 字符串 (e.g., 'cover', 'contain', '100px 50%', 'auto')
   * @param containerWidth 容器宽度
   * @param containerHeight 容器高度
   * @param imgWidth 图片原始宽度
   * @param imgHeight 图片原始高度
   * @returns { width: number, height: number } 计算后的绘制宽高
   */
  parseBackgroundSize(
    sizeStr: string, 
    containerWidth: number, 
    containerHeight: number, 
    imgWidth: number, 
    imgHeight: number
  ): { width: number; height: number } {
    if (!sizeStr || sizeStr === 'auto') {
      return { width: imgWidth, height: imgHeight };
    }

    const parts = sizeStr.trim().split(/\s+/);
    let wPart = parts[0];
    let hPart = parts[1] || wPart; // 如果只提供一个值,第二个值默认为 auto (但在 cover/contain 中逻辑不同,这里简化处理)

    // 处理关键字
    if (wPart === 'cover' || wPart === 'contain') {
      const ratio = imgWidth / imgHeight;
      const containerRatio = containerWidth / containerHeight;
      let finalW, finalH;

      if (wPart === 'cover') {
        // 覆盖:保持比例,填满容器,可能裁剪
        if (ratio > containerRatio) {
          finalH = containerHeight;
          finalW = finalH * ratio;
        } else {
          finalW = containerWidth;
          finalH = finalW / ratio;
        }
      } else { // contain
        // 包含:保持比例,完全显示在容器内,可能有留白
        if (ratio > containerRatio) {
          finalW = containerWidth;
          finalH = finalW / ratio;
        } else {
          finalH = containerHeight;
          finalW = finalH * ratio;
        }
      }
      return { width: finalW, height: finalH };
    }

    // 处理具体数值或百分比
    const parseVal = (val: string, base: number): number => {
      if (val === 'auto') return 0; // 0 表示由另一边比例决定
      if (val.includes('%')) {
        return (parseFloat(val) / 100) * base;
      }
      return this.getValueByUnit(val);
    };

    let calcW = parseVal(wPart, containerWidth);
    let calcH = parseVal(hPart, containerHeight);

    // 如果一边是 auto,根据图片原始比例计算
    if (calcW === 0 && calcH !== 0) {
      calcW = (imgWidth / imgHeight) * calcH;
    } else if (calcH === 0 && calcW !== 0) {
      calcH = (imgHeight / imgWidth) * calcW;
    } else if (calcW === 0 && calcH === 0) {
      // 都是 auto,使用原始尺寸
      calcW = imgWidth;
      calcH = imgHeight;
    }

    return { width: calcW, height: calcH };
  }

  /**
   * 解析 background-position 属性
   * @param posStr background-position 字符串 (e.g., 'center', '10px 20%', 'left top')
   * @param containerWidth 容器宽度
   * @param containerHeight 容器高度
   * @param bgWidth 背景图绘制宽度
   * @param bgHeight 背景图绘制高度
   * @returns { x: number, y: number } 绘制起始坐标
   */
  parseBackgroundPosition(
    posStr: string, 
    containerWidth: number, 
    containerHeight: number, 
    bgWidth: number, 
    bgHeight: number
  ): { x: number; y: number } {
    if (!posStr) posStr = '0% 0%'; // 默认左上角

    // 简单映射关键字
    const keywordMap: Record<string, string> = {
      'left': '0%', 'center': '50%', 'right': '100%',
      'top': '0%', 'bottom': '100%'
    };

    let parts = posStr.trim().split(/\s+/);
    // 如果只有一个关键字,另一个默认为 center
    if (parts.length === 1) {
      if (['left', 'right', 'center'].includes(parts[0])) {
        parts = [parts[0], 'center'];
      } else if (['top', 'bottom'].includes(parts[0])) {
        parts = ['center', parts[0]];
      } else {
        parts = [parts[0], '50%'];
      }
    }

    let xPart = parts[0];
    let yPart = parts[1];

    // 替换关键字为百分比
    if (keywordMap[xPart]) xPart = keywordMap[xPart];
    if (keywordMap[yPart]) yPart = keywordMap[yPart];

    const parsePos = (val: string, containerSize: number, bgSize: number): number => {
      if (val.includes('%')) {
        const percent = parseFloat(val) / 100;
        // 公式:position = (containerSize - bgSize) * percent
        return (containerSize - bgSize) * percent;
      }
      // 具体像素值
      return this.getValueByUnit(val);
    };

    const x = parsePos(xPart, containerWidth, bgWidth);
    const y = parsePos(yPart, containerHeight, bgHeight);

    return { x, y };
  }

  /**
   * 处理背景重复绘制
   */
  drawRepeatedImage(
    img: any, 
    x: number, y: number, 
    bgWidth: number, bgHeight: number, 
    containerWidth: number, containerHeight: number, 
    repeat: string
  ) {
    const shouldRepeatX = repeat === 'repeat' || repeat === 'repeat-x';
    const shouldRepeatY = repeat === 'repeat' || repeat === 'repeat-y';

    if (!shouldRepeatX && !shouldRepeatY) {
      // no-repeat
      this.ctx.drawImage(img, this.format(x), this.format(y), this.format(bgWidth), this.format(bgHeight));
      return;
    }

    // 计算需要绘制的行列数
    // 注意:如果背景图比容器大,且 no-repeat 方向上不需要重复,则只画一次
    // 这里简化处理:只要允许重复,就铺满容器
    
    const startX = shouldRepeatX ? x : Math.max(0, x); // 如果 x 为负(position导致),可能需要调整逻辑,这里假设 position 计算已处理边界
    const startY = shouldRepeatY ? y : Math.max(0, y);

    // 确定绘制范围
    // 如果 position 导致图片在容器外,我们需要从容器边缘开始画
    // 简化:我们遍历所有可能覆盖容器的瓦片
    
    let currentX = startX;
    // 如果 X 不重复,但 position 导致图片在容器左侧外,我们可能需要调整 currentX 到 0 或者只画一次
    if (!shouldRepeatX) {
       // 仅绘制一次,位置由 parseBackgroundPosition 决定
       this.ctx.drawImage(img, this.format(currentX), this.format(startY), this.format(bgWidth), this.format(bgHeight));
       return; // 单次绘制后直接返回,因为 Y 也不重复或者 X 不重复逻辑已覆盖
    }

    // X 方向重复逻辑
    while (currentX < containerWidth) {
      let currentY = startY;
      
      if (!shouldRepeatY) {
         // Y 不重复,只画一行
         this.ctx.drawImage(img, this.format(currentX), this.format(currentY), this.format(bgWidth), this.format(bgHeight));
         break; 
      }

      // Y 方向重复逻辑
      while (currentY < containerHeight) {
        this.ctx.drawImage(img, this.format(currentX), this.format(currentY), this.format(bgWidth), this.format(bgHeight));
        currentY += bgHeight;
      }
      
      currentX += bgWidth;
    }
  }

  /**
   * 绘制背景图片
   */
  async drawBackgroundImage(rect: ElementStyles, x: number, y: number, width: number, height: number) {
    // 解析 backgroundImage,通常格式为 url("...")
    let url = rect.backgroundImage;
    if (url.startsWith('url(')) {
      url = url.slice(4, -1).replace(/["']/g, '');
    }

    if (!url) return;

    return new Promise((resolve) => {
      const img = this.canvas.createImage();
      
      img.onload = () => {
        // 1. 获取图片原始尺寸
        const imgWidth = img.width;
        const imgHeight = img.height;

        // 2. 解析 background-size
        // 默认值为 'auto',即原始尺寸
        const bgSizeStr = rect.backgroundSize || 'auto';
        const { width: bgDrawWidth, height: bgDrawHeight } = this.parseBackgroundSize(
          bgSizeStr, 
          width, 
          height, 
          imgWidth, 
          imgHeight
        );

        // 3. 解析 background-position
        // 默认值为 '0% 0%' (左上角)
        const bgPosStr = rect.backgroundPosition || '0% 0%';
        const { x: bgX, y: bgY } = this.parseBackgroundPosition(
          bgPosStr, 
          width, 
          height, 
          bgDrawWidth, 
          bgDrawHeight
        );

        // 最终绘制坐标相对于容器左上角 (x, y) 的偏移
        const finalX = x + bgX;
        const finalY = y + bgY;

        // 4. 解析 background-repeat
        // 默认值为 'repeat'
        const repeat = rect.backgroundRepeat || 'repeat';

        // 5. 执行绘制
        // 注意:如果使用了 clip (圆角),ctx 状态已经保存并裁剪,直接绘制即可
        this.drawRepeatedImage(
          img, 
          finalX, 
          finalY, 
          bgDrawWidth, 
          bgDrawHeight, 
          width, 
          height, 
          repeat
        );

        resolve(true);
      };
      
      img.onerror = (err: any) => {
        console.error('Background Image load failed:', url, err);
        resolve(false);
      };
      
      img.src = url + '?' + new Date().getTime() + Math.random();
    });
  }

  /**
   * 渲染边框
   */
  renderBorderNode(rect: ElementStyles) {
    if (rect.borderStyle === 'none' || !rect.borderWidth || parseInt(rect.borderWidth) <= 0) {
      return;
    }

    const width = this.getValueByUnit(rect.width);
    const height = this.getValueByUnit(rect.height);
    const borderWidth = this.getValueByUnit(rect.borderWidth);
    const { x, y } = this.getPosition(rect);

    this.ctx.strokeStyle = rect.borderColor || '#000';
    this.ctx.lineWidth = this.format(borderWidth);
    
    const radii = this.getBorderRadius(rect).map(r => this.format(r));
    
    if (radii.length > 0) {
       // 绘制带圆角的边框路径(radii 需与 x/y/width/height 一样做 DPR 缩放,否则高 DPR 下圆角错位)
       this.beginRoundRectPath(this.format(x), this.format(y), this.format(width), this.format(height), radii);
    } else {
       this.ctx.beginPath();
       this.ctx.rect(this.format(x), this.format(y), this.format(width), this.format(height));
    }
    
    this.ctx.stroke();
  }

  /**
   * 获取圆角数值
   * @description 支持 px 与百分比两种写法。百分比按 CSS 规范相对元素宽高计算,
   *              如 `border-radius: 50%` 返回短边的一半(接近全圆角/胶囊效果)。
   *              返回数组顺序为 [左上, 右上, 右下, 左下],逻辑像素单位(未做 DPR 缩放)。
   */
  getBorderRadius(rect: ElementStyles): number[] {
    const borderRadius = rect.borderRadius;
    if (!borderRadius || borderRadius === '0px' || borderRadius === 'auto') {
      return [];
    }
    
    if (typeof borderRadius === 'string') {
      const width = this.getValueByUnit(rect.width);
      const height = this.getValueByUnit(rect.height);
      const shortSide = Math.min(width, height);

      // 解析单个圆角值,支持 px / 百分比
      const parseRadius = (val: string): number => {
        if (val.includes('%')) {
          const percent = parseFloat(val) / 100;
          return shortSide * percent;
        }
        return this.getValueByUnit(val);
      };

      const parts = borderRadius.split(' ').map(parseRadius);
      if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
      if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
      if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
      if (parts.length === 4) return parts;
    }
    return [];
  }

  /**
   * 获取相对于根容器的坐标
   * 由于传入的是预处理数据,假设 left/top 已经是相对于 Canvas 容器的坐标
   * 如果原始数据是相对于页面的,则需要减去 rootRect 的 offset
   */
  getPosition(rect: ElementStyles) {
    // 如果 rootRect 存在,且传入的坐标是页面绝对坐标,则转换
    // 如果调用方已经处理好了相对坐标,则直接使用
    // 这里保留原逻辑的转换思路,但依赖于 rootRect 是否被正确设置
    
    if (this.rootRect) {
      // 假设 rect.left 是页面绝对位置,rootRect.left 也是
      const x = rect.left - this.rootRect.left;
      const y = rect.top - this.rootRect.top;
      return { x, y };
    }
    
    // 否则直接使用
    return { x: rect.left, y: rect.top };
  }

  /**
   * DPR 适配
   */
  format(val: number) {
    return val * this.dpr;
  }

  /**
   * 去除单位转数字
   */
  getValueByUnit(val: string | number): number {
    if (typeof val === 'number') return val;
    if (!val) return 0;
    return Number(val.replace(/[a-zA-Z%]/g, '')) || 0;
  }

  /**
   * 解析单个 padding 值
   * @param val 单个 padding 值字符串,如 '10px'、'5%'、'16rpx'
   * @param baseWidth 元素宽度(逻辑像素),用于百分比换算
   * @returns { value, unit } 数值与单位;百分比已按宽度换算为实际像素值(unit 标记为 'px')
   */
  private parsePaddingValue(val: string, baseWidth: number): { value: number; unit: string } {
    const trimmed = (val || '').trim();
    // 匹配「数字 + 可选单位」,支持小数与负数
    const match = trimmed.match(/^(-?\d+(?:\.\d+)?)([a-zA-Z%]*)$/);
    if (!match) return { value: 0, unit: 'px' };

    const num = parseFloat(match[1]);
    const unit = match[2] || 'px';

    if (unit === '%') {
      // CSS 规范:padding 百分比相对包含块(元素)宽度计算,含 top/bottom
      return { value: (num / 100) * baseWidth, unit: 'px' };
    }

    // 其他单位(px/rpx/em 等)正常提取数字与单位返回
    return { value: num, unit };
  }

  /**
   * 解析元素的 padding 值
   * @description 支持简写(1/2/3/4 值)与四方向独立属性(paddingTop 等)。
   *              独立方向属性优先于简写;百分比 padding 按元素宽度换算为实际像素值。
   * @param rect 元素样式
   * @returns 按 top/right/bottom/left 嵌套返回,每个方向包含数值与单位
   * @example
   * rect.padding = '10px 20%' → {
   *   top:    { value: 10, unit: 'px' },
   *   right:  { value: 20, unit: 'px' },  // 已按宽度换算
   *   bottom: { value: 10, unit: 'px' },
   *   left:   { value: 20, unit: 'px' },
   * }
   */
  getPadding(rect: ElementStyles): {
    top: { value: number; unit: string };
    right: { value: number; unit: string };
    bottom: { value: number; unit: string };
    left: { value: number; unit: string };
  } {
    const baseWidth = this.getValueByUnit(rect.width);
    const resolve = (val?: string) => this.parsePaddingValue(val || '', baseWidth);

    // 1. 独立方向属性优先(如 paddingTop),比简写更精确
    if (rect.paddingTop || rect.paddingRight || rect.paddingBottom || rect.paddingLeft) {
      return {
        top: resolve(rect.paddingTop),
        right: resolve(rect.paddingRight),
        bottom: resolve(rect.paddingBottom),
        left: resolve(rect.paddingLeft),
      };
    }

    // 2. 处理简写 padding
    const parts = (rect.padding || '0px').trim().split(/\s+/).map(resolve);

    if (parts.length === 1) {
      const [v] = parts;
      return { top: v, right: v, bottom: v, left: v };
    }
    if (parts.length === 2) {
      const [v, h] = parts;
      return { top: v, right: h, bottom: v, left: h };
    }
    if (parts.length === 3) {
      const [t, h, b] = parts;
      return { top: t, right: h, bottom: b, left: h };
    }
    // 4 值:上 右 下 左
    const [t, r, b, l] = parts;
    return { top: t, right: r, bottom: b, left: l };
  }

  /**
   * 绘制文本
   */
  drawText(rect: ElementStyles) {
    if (!rect.text) return;

    const { x, y } = this.getPosition(rect);
    const fontSize = this.getValueByUnit(rect.fontSize);
    let width = this.getValueByUnit(rect.width);
    let height = this.getValueByUnit(rect.height);

    /// 判断是否为渐变字
    const isGradientText = rect.backgroundImage?.includes('linear-gradient') && rect.backgroundClip?.includes('text');

    // 1. 设置字体样式 (必须在 measureText 之前设置,否则测量宽度不准)
    this.ctx.font = `${rect.fontWeight || 'normal'} ${this.format(fontSize)}px ${rect.fontFamily || 'sans-serif'}`;
    
    // 2. 处理宽高为 0 的情况 (兜底)
    if (width <= 0) {
      const metrics = this.ctx.measureText(rect.text);
      width = metrics.width / this.dpr;
    }
    if (height <= 0) {
      height = fontSize; // 或者根据 lineHeight 计算
    }

    // 3. 关键:确定绘制的参考坐标和对齐方式
    const textAlign = rect.textAlign || 'left';
    this.ctx.textAlign = textAlign;
    this.ctx.textBaseline = 'middle'; // 垂直方向我们手动控制基线

    // 4. 计算水平绘制的起始 X 坐标
    let drawX = x;
    if (textAlign === 'center') {
      drawX = x + width / 2;
    } else if (textAlign === 'right') {
      drawX = x + width;
    }

    // 5. 计算垂直绘制的 Y 坐标 (垂直居中)
    const centerY = y + height / 2;

    // 6. 处理多行文本或自动换行
    const lineHeight = this.getValueByUnit(rect.lineHeight) || fontSize * 1.2;
    // 以 DOM 真实高度推算的行数作为换行上限,避免不同机型测量误差多切出一行
    const maxLines = this.getMaxLines(rect, lineHeight);
    const lines = this.getLines(rect.text, width, maxLines, fontSize);
    const totalTextHeight = lines.length * lineHeight;

    // 重新计算起始 Y,使得整个文本块在 height 中垂直居中
    let startY = centerY - totalTextHeight / 2 + lineHeight / 2;

    /// ================= 核心逻辑:处理渐变文字 =================
    if (isGradientText) {
      // 解析渐变字符串
      const gradientData = this.parseGradient(rect.backgroundImage);
      
      if (gradientData && gradientData.colorStops.length > 0) {
        const { angle, colorStops } = gradientData;
        const radians = (angle - 90) * (Math.PI / 180); // CSS角度转数学弧度
        
        // 测量完整文本块的宽高,用于确定渐变色的覆盖范围
        // 注意:渐变范围应基于整个文本块,而不是单行,否则每行渐变会重新开始
        const maxLineWidth = Math.max(...lines.map(line => this.ctx.measureText(line).width / this.dpr));
        const gradientWidth = maxLineWidth;
        const gradientHeight = totalTextHeight;

        // 计算渐变线在文本块上的交点
        const halfDiagonal = Math.abs(gradientWidth * Math.cos(radians)) + Math.abs(gradientHeight * Math.sin(radians));
        const centerX = drawX; // 渐变中心 X 基于 textAlign 的 drawX
        const centerYForGradient = startY - lineHeight / 2 + totalTextHeight / 2; // 整个文本块的垂直中心

        const x1 = centerX - (Math.cos(radians) * halfDiagonal) / 2;
        const y1 = centerYForGradient - (Math.sin(radians) * halfDiagonal) / 2;
        const x2 = centerX + (Math.cos(radians) * halfDiagonal) / 2;
        const y2 = centerYForGradient + (Math.sin(radians) * halfDiagonal) / 2;

        // 创建 Canvas 线性渐变对象
        const gradient = this.ctx.createLinearGradient(x1, y1, x2, y2);

        // 添加颜色断点 (已在 parseGradient 中转为 Hex 格式)
        colorStops.forEach((stop: { offset: number; color: string }) => {
          gradient.addColorStop(stop.offset, stop.color);
        });

        // 将渐变对象赋值给 fillStyle
        this.ctx.fillStyle = gradient;
      } else {
        // 解析失败兜底
        this.ctx.fillStyle = rect.color || '#000';
      }
    } else {
      // 普通单色文字
      this.ctx.fillStyle = rect.color || '#000';
    }
    /// ================= 核心逻辑结束 =================

    // 7. 遍历绘制文本行
    lines.forEach((line, index) => {
      const currentY = startY + index * lineHeight;
      this.ctx.fillText(line, this.format(drawX), this.format(currentY), this.format(width));
    });

    // 8. 绘制装饰线
    if (rect.textDecoration && rect.textDecoration !== 'none') {
      const firstLineWidth = this.ctx.measureText(lines[0]).width / this.dpr;
      
      let lineX = x;
      if (textAlign === 'center') lineX = x + (width - firstLineWidth) / 2;
      if (textAlign === 'right') lineX = x + width - firstLineWidth;

      this.ctx.fillStyle = rect.color || '#000';

      this.drawTextDecoration({
        x: lineX, 
        y: startY, 
        text: lines[0], 
        textWidth: firstLineWidth, 
        fontSize
      }, rect.textDecoration);
    }
  }


  /**
   * 辅助函数:简单的文本换行处理
   * @param text      待换行文本
   * @param maxWidth  单行可用的最大宽度(逻辑像素)
   * @param maxLines  允许的最大行数(由 DOM 真实高度推算,见 getMaxLines)
   * @param fontSize  字号(逻辑像素),用于放大容差
   * @description 不同机型(字体回退、亚像素渲染、DPR 取整)下 measureText 的测量结果
   *              与浏览器/CSS 实际排版宽度存在微小差异,且字号越大、字重越粗(加粗)
   *              差异越明显。若严格按 > maxWidth 判断,会导致本应单行的文本被误切成两行,
   *              进而使文本块垂直居中错乱。这里做三重防护:
   *              1. 引入随字号、宽度变化的容差;
   *              2. 整段文本能放进一行时直接作为一行,避免逐字符累加的误差;
   *              3. 行数不超过 maxLines,避免多切出一行。
   */
  getLines(text: string, maxWidth: number, maxLines: number = Infinity, fontSize: number = 0): string[] {
    const lines: string[] = [];
    // 容差:至少 1px,同时随宽度和字号放大,用于吸收不同机型的测量误差
    const tolerance = Math.max(1, maxWidth * 0.02, fontSize * 0.1);

    const paragraphs = text.split('\n'); // 先按显式换行符分割

    paragraphs.forEach(paragraph => {
      if (paragraph === '') {
        lines.push('');
        return;
      }

      // 整段文本能放进一行时直接返回,避免逐字符测量累加带来的误差
      if (this.measureTextWidth(paragraph) <= maxWidth + tolerance) {
        lines.push(paragraph);
        return;
      }

      let currentLine = '';
      // 使用 for...of 按码点遍历,避免 emoji 等代理对被拆开
      for (const char of Array.from(paragraph)) {
        const testLine = currentLine + char;
        const testWidth = this.measureTextWidth(testLine);

        // 已到达 DOM 实际行数上限时不再换行,防止测量误差导致多出一行
        if (testWidth > maxWidth + tolerance && currentLine !== '' && lines.length < maxLines) {
          lines.push(currentLine);
          currentLine = char;
        } else {
          currentLine = testLine;
        }
      }
      lines.push(currentLine);
    });

    return lines;
  }

  /**
   * 根据 DOM 真实渲染高度推算文本所占的行数上限
   * @description 节点的 height 是浏览器/CSS 真实排版的结果,比 canvas 的 measureText 可靠。
   *              对于单行文本 height 约等于一个 lineHeight,据此可判定其只占一行,
   *              从而不受 canvas 测量误差影响。推算偏大时仅相当于放宽上限,不会产生副作用。
   */
  getMaxLines(rect: ElementStyles, lineHeight: number): number {
    const height = this.getValueByUnit(rect.height);
    if (height <= 0 || lineHeight <= 0) return Infinity;
    return Math.max(1, Math.round(height / lineHeight));
  }

  /**
   * 测量文本宽度(转换为逻辑像素)
   */
  measureTextWidth(text: string): number {
    return this.ctx.measureText(text).width / this.dpr;
  }

  /**
   * 绘制文本装饰线
   */
  drawTextDecoration(config: TextRenderConfig, textDecoration: string) {
    // 简单解析 decoration,例如 "underline red" 或 "line-through"
    const parts = textDecoration.split(' ');
    const type = parts[0];
    const color = parts[1] || config.textWidth > 0 ? '#000' : '#000'; // 默认黑色

    this.ctx.beginPath();
    this.ctx.strokeStyle = color;
    this.ctx.lineWidth = this.format(1); // 默认1px

    let lineY = 0;
    if (type === 'underline') {
      lineY = config.y + config.fontSize;
    } else if (type === 'line-through') {
      lineY = config.y + config.fontSize / 2;
    } else if (type === 'overline') {
      lineY = config.y;
    }

    if (lineY) {
      this.ctx.moveTo(this.format(config.x), this.format(lineY));
      this.ctx.lineTo(this.format(config.x + config.textWidth), this.format(lineY));
      this.ctx.stroke();
    }
  }

  /**
   * 将 Base64 图片转换为本地临时文件路径
   * @param base64 Base64 字符串 (支持带 data:image/xxx;base64, 前缀)
   * @returns Promise<string> 本地临时文件路径
   */
  base64ToTempFilePath(base64: string): Promise<string> {
    return new Promise((resolve, reject) => {
      if (!base64) {
        reject(new Error('Base64 is empty'));
        return;
      }

      // 1. 去除 Base64 前缀 (data:image/png;base64,)
      const base64Data = base64.replace(/^data:image\/\w+;base64,/, '');
      
      // 2. 生成唯一的文件名
      const fileName = `${Date.now()}_${Math.random().toString(36).slice(2)}.png`;
      const filePath = `${uni.env.USER_DATA_PATH}/${fileName}`;

      // 3. 将 Base64 写入本地文件
      uni.getFileSystemManager().writeFile({
        filePath: filePath,
        data: base64Data,
        encoding: 'base64',
        success: () => {
          resolve(filePath);
        },
        fail: (err) => {
          console.error('Base64 to file failed:', err);
          reject(err);
        }
      });
    });
  }

  /**
   * 绘制图片
   */
  async drawImage(rect: ElementStyles) {
    if (!rect.src) return;

    let src = rect.src;

    const { x, y } = this.getPosition(rect);
    const width = this.getValueByUnit(rect.width);
    const height = this.getValueByUnit(rect.height);
    
    // 获取圆角信息
    const radii = this.getBorderRadius(rect);
    const hasRadius = radii.length > 0 && radii.some(r => r > 0);

    // 针对base64图片做处理 - 部分机型无法识别
    if (rect.src.startsWith('data:image/')) {
      try {
        src = await this.base64ToTempFilePath(rect.src)
      } catch {
        console.warn('base64图片处理失败')
      }
    } else {
      // 处理网络图片
      src = src + '?' + new Date().getTime() + Math.random();
    }

    return new Promise((resolve) => {
      const img = this.canvas.createImage();
      
      img.onload = () => {
        // 如果存在圆角,需要进行裁剪处理
        if (hasRadius) {
          this.ctx.save();
          try {
            // 创建圆角裁剪路径
            const formattedRadii = radii.map(r => this.format(r));
            const formattedX = this.format(x);
            const formattedY = this.format(y);
            const formattedW = this.format(width);
            const formattedH = this.format(height);

            this.beginRoundRectPath(formattedX, formattedY, formattedW, formattedH, formattedRadii);
            this.ctx.clip();
            // 清空当前路径,防止跨节点累积导致后续 clip 区域被污染
            this.ctx.beginPath();
            
            // 在裁剪区域内绘制图片
            this.ctx.drawImage(img as any, formattedX, formattedY, formattedW, formattedH);
          } catch (e) {
            // 兼容性兜底:roundRect/clip 失败时降级为不带圆角直接绘制,避免整图丢失
            console.warn('图片圆角裁剪失败,已降级为直接绘制:', e);
            this.ctx.drawImage(img as any, this.format(x), this.format(y), this.format(width), this.format(height));
          } finally {
            // 无论如何都要恢复 ctx 状态,防止影响后续节点绘制
            this.ctx.restore();
          }
        } else {
          // 无圆角,直接绘制
          this.ctx.drawImage(img as any, this.format(x), this.format(y), this.format(width), this.format(height));
        }
        
        resolve(true);
      };
      
      img.onerror = (err: any) => {
        console.error('Image load failed:', src, err);
        resolve(false);
      };
      
      img.src = src;
    });
  }

  /**
   * 导出为临时文件
   */
  toTempFilePath(options?: any): Promise<string> {
    return new Promise((resolve, reject) => {
      // UniApp 标准 API
      uni.canvasToTempFilePath({
        canvas: this.canvas,
        ...options,
        success: (res) => {
          resolve(res.tempFilePath);
        },
        fail: (err) => {
          reject(err);
        }
      }, this.component); // 传递 component 上下文,特别是在小程序中
    });
  }
}

 

 组件封装layout-snapshot

LayoutSnapshot
<template>
  <view ref="snapshotRef" id="snapshot">
    <slot></slot>
  </view>
</template>
<script lang="ts">
import UniNode2Canvas from '@/utils/plugins/node2canvas';
import { defineComponent, getCurrentInstance, ref } from 'vue';

type TakeSnapshotOptions = {
  getNodes: () => Promise<any>;
  autoSave?: boolean;
}

export default defineComponent({
  name: 'LayoutSnapshot',
  setup(props, _ctx) {
    const snapshotRef = ref(null);
    const instance = getCurrentInstance()?.proxy;
    const takeSnapshot = (options: TakeSnapshotOptions) => {
      // 创建离屏canvas
      const canvas = uni.createOffscreenCanvas({
        type: '2d',
        compInst: instance,
      })

      return new Promise((resolve, reject) => {
        // 执行获取节点信息
        options.getNodes().then(nodes => {
          
          const _nodes = nodes.map((node: any) => {
            return {
              ...node,
              ...node.dataset
            }
          })
          
          const snapshot = new UniNode2Canvas({
            nodes: _nodes,
            canvas,
            onDone: function() {
              console.log('done');
              // 获取图片
              this.toTempFilePath().then(res => {
                console.log(res);

                if (options.autoSave) {
                  // 如果需要保存图片
                  uni.saveImageToPhotosAlbum({
                    filePath: res,
                    success: function () {
                      console.log('保存成功');
                    },
                    fail: function (err) {
                      console.log(err);
                    }
                  })
                }

                resolve(res)
              })
            }
          })
  
          snapshot.draw();
        })
      })
      
    }

    _ctx.expose({
      takeSnapshot
    })

    return {
      snapshotRef,
      takeSnapshot,
    }
  }
})
</script>
<style lang="scss" scoped>
#snapshot-canvas {
  width: 622rpx;
  height: 900rpx;
  position: absolute;
}
</style>

 提供辅助函数useSnapshotHelper.ts

useSnapshotHelper
export const useSnapshotHelper = () => {
  const getNodes = (instance: any, nodeClass: string = '.snapshot') => {
    return new Promise((resolve, reject) => {
      uni.createSelectorQuery().in(instance).selectAll(nodeClass).fields({
        dataset: true,
        size: true,
        rect: true,
        computedStyle: ['width', 'height', 'font', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'textAlign', 
          'color', 'lineHeight', 'border', 'borderColor', 'borderStyle', 'borderWidth', 'borderRadius', 'verticalAlign', 'boxShadow', 'boxSizing',
          'background', 'backgroundColor', 'backgroundRepeat', 'backgroundImage', 'backgroundPosition', 'backgroundSize', 'paddingLeft', 'paddingTop',
          'paddingRight', 'paddingBottom', 'textDecoration', 'backgroundClip'
        ],
        properties: ['src']
      }, (res) => {
        // 这里获取到的子元素一定是数组
        resolve(res);
      }).exec()
    })
  }

  return {
    getNodes
  }
};

  如何使用?

使用useSnapshotHelper提供的getNodes方法 传入当前组件的instance和className名称。

 

比如

演示代码
<layout-snapshot ref="snapshotRef" >
       
<view class="snapshot container-content-head">
      <view class="snapshot container-content-head-icon">
        <image data-type="image" class="snapshot container-content-head-icon-image" :src="data.brandFileId" />
      </view>
      <view class="snapshot container-content-head-info">
        <text data-type="text" :data-text="data.brandName" class="snapshot container-content-head-info-title">{{ data.brandName }}</text>
        <text data-type="text" data-text="我是文字" class="snapshot container-content-head-info-desc">我是文字</text>
      </view>
      <view class="snapshot container-content-head-image">
        <image data-type="image" class="snapshot container-content-head-image-image" :src="data.carSeriesCoverFileId" />
      </view>
    </view>
</layout-snapshot>



snapshotRef.value.takeSnapshot({
            getNodes: getNodes,
            autoSave: false
          }).then((res: any) => {
    console.log(res)
            tipRef.value.open({
              title: '提示',
              content: '海报生成成功,点击确认即可分享到微信好友',
              confirm: (_close: () => void) => {
                _close();
              }
            })
          }).catch(() => {
            tipInstace.hideLoading();
            tipInstace.showToast('海报生成失败,请再试一次');
          })

 

  文本和图片需要为其添加data-type、data-text属性

 

posted @ 2026-10-12 17:26  ZiWeiter  阅读(3)  评论(0)    收藏  举报
刷新页面返回顶部
博客园  ©  2004-2026
浙公网安备 33010602011771号 浙ICP备2021040463号-3