`
然并卵-277
  • 浏览: 19547 次
  • 性别: Icon_minigender_2
社区版块
存档分类
最新评论

BMP格式解析与保存 画图板的重绘

阅读更多

实现画图板重绘的两种方法
1.抽象Shape类 每次画的时候 都保存在ArrayList中 当窗体改变 自动调用paint方法时  取出ArrayList中保存的Shape对象
	public void paint(Graphics g) {
	    super.paint(g);// 将面板本身绘制在屏幕上
                      // 当面板发生改变的时候,
		//将ArrayList中保存的形状对象取出来,重新绘制
            for(int i=0;i<DrawListener.list.size();i++){
	    Shape shape = DrawListener.list.get(i);
		shape.draw(g);
		}
    }


2.截屏 每次释放的时候 用二维数组保存屏幕上的点
 // 释放一次,就重新保存一次
 	// 截屏
    Point point= drawJPanel.getLocationOnScreen();
   Dimension dim=drawJPanel.getPreferredSize();
   Rectangle screenrect=new Rectangle(point,dim);
   BufferedImage bufferedImg=robot.createScreenCapture(screenrect);
   // 根据面板大小调整数组大小
   array=new int[dim.height][dim.width];
    for(int i=0;i<array.length;i++){
 	for(int j=0;j<array[i].length;j++){
 	array[i][j]=bufferedImg.getRGB(j, i);
 	}
 }


先介绍一下有关BMP的基础知识:

BMP 是一种与硬件设备无关的图像文件格式,使用非常广。它采用位映射存储格式,除了图 像深度可选以外,不采用其他任何压缩,因此, BMP 文件所占用的空间很大。 BMP 文件的图 像深度可选 lbit 、 4bit 、 8bit 及 24bit 。 BMP 文件存储数据时,图像的扫描方式是按从左 到右、从下到上的顺序。由于 BMP 文件格式是 Windows 环境中交换与图有关的数据的一种标 准,因此在 Windows 环境中运行的图形图像软件都支持 BMP 图像格式。

要解析文件,就必须知道他的文件结构:

BMP 文件结构
典型的 BMP 图像文件由四部分组成:
1 . 位图文件 头数据结构 ,它包含 BMP 图像文件的类型、显示内容等信息;
2 .位图信息数据结构 ,它包含有 BMP 图像的宽、高、压缩方法,以及定义颜色等信息;
3. 调色板 ,这个部分是可选的,有些位图需要调色板,有些位图,比如真彩色图(24 位 的 BMP )就不需要调色板;
4. 位图数据 ,这部分的内容根据 BMP 位图使用的位数不同而不同,在 24 位图中直 接使用 RGB ,而其他的小于 24 位的使用调色板中颜色索引值。

对应的数据结构
① BMP 文件头 (14 字节 )
BMP 文件头数据结构含有 BMP 文件的类型、文件大小和位图起始位置等信息。其结构定 义如下:
int bfType; // 位图文件的类型,必须为 ' B '' M '两个字母 (0-1字节 )
int bfSize; // 位图文件的大小,以字节为单位 (2-5 字节 )
int usignedshort bfReserved1; // 位图文件保留字,必须为 0(6-7 字节 )
int usignedshort bfReserved2; // 位图文件保留字,必须为 0(8-9 字节 )
int bfOffBits; // 位图数据的起始位置,以相对于位图 (10-13 字节 )
int bfOffBits;// 文件头的偏移量表示,以字节为单位

② 位图信息头(40 字节 )
BMP 位图信息头数据用于说明位图的尺寸等信息。
int Size; // 本结构所占用字节数 (14-17 字节 )
int image_width; // 位图的宽度,以像素为单位 (18-21 字节 )
int image_heigh; // 位图的高度,以像素为单位 (22-25 字节 )

int Planes; // 目标设备的级别,必须为 1(26-27 字节 )
int biBitCount;// 每个像素所需的位数,必须是 1(双色),(28-29 字节) 4(16
色 ) , 8(256 色 ) 或 24(// 真彩色 ) 之一
int biCompression; // 位图压缩类型,必须是 0( 不压缩 ),(30-33 字节 ) 1(BI_RLE8 压缩类型 ) 或// 2(BI_RLE4 压缩类型 ) 之一
int SizeImage; // 位图的大小,以字节为单位 (34-37 字节 )
int biXPelsPerMeter; // 位图水平分辨率,每米像素数 (38-41 字节 ) int biYPelsPerMeter; // 位图垂直分辨率,每米像素数 (42-45 字节 ) int biClrUsed;// 位图实际使用的颜色表中的颜色数 (46-49 字节 )
int biClrImportant;// 位图显示过程中重要的颜色数 (50-53 字节 )


③ 颜色表
颜色表用于说明位图中的颜色,它有若干个表项,每一个表项是一个 RGBQUAD 类型的结 构,定义一种颜色。
class RGBQUAD {
byte rgbBlue;// 蓝色的亮度 ( 值范围为 0-255)
byte rgbGreen; // 绿色的亮度 ( 值范围为 0-255)
byte rgbRed; // 红色的亮度 ( 值范围为 0-255)
byte rgbReserved;// 保留,必须为 0
}
颜色表中 RGBQUAD 结构数据的个数有 biBitCount 来确定。当 biBitCount=1,4,8 时,分别有 2,16,256 个表项 ; 当 biBitCount=24 时,没有颜色表项。 位图信息头和颜色表组成位图信息,BITMAPINFO 结构定义如下 :
class BITMAPINFO {
BITMAPINFOHEADER bmiHeader; // 位图信息头
RGBQUAD bmiColors[1]; // 颜色表
}

④ 位图数据
位图数据记录了位图的每一个像素值,记录顺序是在扫描行内是从左到右,扫描行之间是从 下到上。位图的一个像素值所占的字节数:
当 biBitCount=1 时, 8 个像素占 1 个字节 ; 当 biBitCount=4 时, 2 个像素占 1 个字节 ; 当 biBitCount=8 时, 1 个像素占 1 个字节 ; 当 biBitCount=24 时 ,1 个像素占 3 个字节 ;
Windows 规定一个扫描行所占的字节数必须是 4 的倍数 ( 即以 long 为单位 ), 不足的以 0填充



简单来说 文件格式保存 就是 怎么写进去 就怎么读出来 但是其中转换格式还是很麻烦的
通过网上查资料 BMP解析可以有两种方式实现 其实方法差不多 原理都一样

方法一:

package BMP_Color;

import java.awt.Color;
import java.io.DataOutputStream;
import java.io.FileOutputStream;

public class BMPWrite24 {

	
	Color [][] color;
	int width,height;
	String path;
	public BMPWrite24(Color [][] color,String path){
		this.color=color;
		this.height=color.length;
		this.width=color[0].length;
		this.path=path;
		this.write();
	}
	public void write(){
		try {
	FileOutputStream fos=new FileOutputStream(path);
	DataOutputStream dos=new DataOutputStream(fos);
			
		
         int bfSize=54+width*height*3+(4 - width * 3 % 4) * height;
         int bfReserved1=0;
	int bfReserved2=0;
	int bfOffBits=54;
			
	dos.write('B');
	dos.write('M');
	dos.write(ChangeByte(bfSize),0,4);
	dos.write(ChangeByte(bfReserved1),0,2);
	dos.write(ChangeByte(bfReserved2),0,2);
	dos.write(ChangeByte(bfOffBits),0,4);
			
			
	int size=40;
	int image_width=width;
	int image_height=height;
	int Planes=1;
	int biBitCount=24;
	int biCompression=0;
	int SizeImage=width*height;
	int biXPelsPerMeter=0;
	int biYPelsPerMeter=0;
	int biClrUsed=0;
	int biClrImportant=0;
	
	// 因为java是大端存储,那么也就是说同样会大端输出。
	// 但计算机是按小端读取,如果我们不改变多字节数据的顺序的话,那么机器就不能正常读取。
	// 所以首先调用方法将int数据转变为多个byte数据,并且按小端存储的顺序。
		
	dos.write(ChangeByte(size),0,4);
	dos.write(ChangeByte(image_width),0,4);
	dos.write(ChangeByte(image_height),0,4);
	dos.write(ChangeByte(Planes),0,2);
         dos.write(ChangeByte(biBitCount),0,2);
         dos.write(ChangeByte(biCompression),0,4);
         dos.write(ChangeByte(SizeImage),0,4);
         dos.write(ChangeByte(biXPelsPerMeter),0,4);
         dos.write(ChangeByte(biYPelsPerMeter),0,4); 
         dos.write(ChangeByte(biClrUsed),0,4);
         dos.write(ChangeByte(biClrImportant),0,4);
            
          int skip=0;
            if(!(width*3%4==0)){
            	skip=4-width*3%4;
            }
            
           // 因为是24位图,所以没有颜色表
	  // 通过遍历输入位图数据
	  // 这里遍历的时候注意,在计算机内存中位图数据是从左到右,从下到上来保存的,
	 // 也就是说实际图像的第一行的点在内存是最后一行
            for(int i=height-1;i>=0;i--){
            	for(int j=0;j<width;j++){
            		
            	int red=color[i][j].getRed();
            	int green=color[i][j].getGreen();
            	int blue=color[i][j].getBlue();
            	
            	byte[] r=ChangeByte(red);
            	byte[] g=ChangeByte(green);
            	byte[] b=ChangeByte(blue);
            	dos.write(b,0,1);
            	dos.write(g,0,1);
                  dos.write(r,0,1);  
                
        		for (int k = 0; k < skip; k++) {
    		   dos.write(0);
    			}
            	}
            	
            }
            dos.flush();
			dos.close();
			fos.close();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	public byte[]  ChangeByte(int data){
		byte b4=(byte)((data)>>24);
		byte b3=(byte)(((data)<<8)>>24);
		byte b2=(byte)(((data)<<16)>>24);
		byte b1=(byte)(((data)<<24)>>24);
		byte [] b={b1,b2,b3,b4};
		return b;
	}

}


package BMP_Color;

import java.awt.Color;
import java.io.DataInputStream;
import java.io.FileInputStream;

import javax.swing.JOptionPane;

public class  BMPReader24{
    MyPanel panel;
    String path;
    int [][] red,green,blue;
   public BMPReader24(MyPanel panel,String  path){
	this.panel=panel;
	this.path=path;
	this.reader();
    }
	public void reader(){
     try {
	FileInputStream fis=new FileInputStream(path);
	DataInputStream dis=new DataInputStream(fis);
			
	int biLen=14;
	byte[] bi=new byte[biLen];
	dis.read(bi, 0, biLen);
			
	int bfLen=40;
	byte[] bf=new byte[bfLen];
	dis.read(bf, 0, bfLen);
			
	int width=ChangeInt(bf, 7);
	int height=ChangeInt(bf, 11);

	red=new int [height][width];
	green=new int[height][width];
	blue=new int[height][width];


	// 通过计算得到每行计算机需要填充的字符数。
	// 为什么要填充?这是因为windows系统在扫描数据的时候,每行都是按照4个字节的倍数来读取的。
	// 因为图片是由每个像素点组成。而每个像素点都是由3个颜色分量来构成的,而每个分量占据1个字节。
	// 因此在内存存储中实际图片数据每行的长度是width*3。
	int skip=0;
	if(!(width*3%4==0)){
	           skip=4-width*3%4;
	      }
	for(int i=height-1;i>=0;i--){
	   for(int j=0;j<width;j++){
				 
	     blue[i][j]=dis.read();
	     green[i][j]=dis.read();
	     red[i][j]=dis.read();
				  
        DrawListener.color[i][j]=new Color(red[i][j],green[i][j],blue[i][j]);
	 if(j==0){
	           dis.skipBytes(skip);
	        }
           }
         }
	 panel.repaint();
	} catch (Exception e) {
	JOptionPane.showMessageDialog(null, "文件打开失败!!");
	e.printStackTrace();
	}
   }
	public int ChangeInt(byte[] array2, int start) {
// 因为char,byte,short这些数据类型经过运算符后会自动转为成int数据类,
// 所以array2[start]&0xff的实际意思就是通过&0xff将字符数据转化为正int数据,然后在进行位运算。
// 这里需要注意的是<<的优先级别比&高,所以必须加上括号。

        int i = (int) ((array2[start] & 0xff) << 24)
		| ((array2[start - 1] & 0xff) << 16)
		| ((array2[start - 2] & 0xff) << 8)
		| (array2[start - 3] & 0xff);
		return i;
	}

}


方法二:


import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import javax.swing.JFileChooser;
import javax.swing.JOptionPane;

/**
 * 菜单处理监听器
 * 
 * @author kowloon
 * 
 */
public class MenuListener implements ActionListener {

	private MyPanel panel;

	public MenuListener(MyPanel panel) {
		this.panel = panel;
	}
	// bmp文件头
	public void savebmpTop(OutputStream ops) throws Exception {
		ops.write('B');
		ops.write('M');
	    int height = DrawListener.array.length;
	    int width = DrawListener.array[0].length;
             int size = 14 + 40 + height * width * 3 + (4 - width * 3 % 4) * height;
		// 位图文件的大小
		size = 14 + 40 + height * width * 3 + (4 - width * 3 % 4) * 255;
		writeInt(ops, size);
		// 保留字节,必须为零
		writeShort(ops, (short) 0);
		writeShort(ops, (short) 0);
		// 位图偏移量
		writeInt(ops, 54);
	}

	// 位图信息头
	public void savebmpInfo(OutputStream ops) throws Exception {
		int height = DrawListener.array.length;
		int width = DrawListener.array[0].length;
	
		// 位图信息头长度
		writeInt(ops, 40);
		// 位图宽
		writeInt(ops, width);
		// 位图高
		writeInt(ops, height);
		// 位图位面数总是为1
		writeShort(ops, (short) 1);
		// 位图24位像素
		writeShort(ops, (short) 24);
		// 位图是否被压缩,0为不压缩
		writeInt(ops, 0);
		// 字节数代表位图大小
		writeInt(ops, height * width * 3 + (4 - width * 3 % 4) * height);
		// 水平分辨率
		writeInt(ops, 0);
		// 垂直分辨率
		writeInt(ops, 0);
		// 颜色索引,0为所有调色板
		writeInt(ops, 0);
		// 对图象显示有重要影响的颜色索引的数目。如果是0,表示都重要
		writeInt(ops, 0);
	}

	// 图像数据阵列
	public void savebmpDate(OutputStream ops) throws Exception {
		int height = DrawListener.array.length;
		int width = DrawListener.array[0].length;
	
		int m = 0;
		// 进行补0
		if (width * 3 % 4 > 0) {
			m = 4 - width * 3 % 4;
		}
		for (int i = height - 1; i >= 0; i--) {
			for (int j = 0; j < width; j++) {
				int t = DrawListener.array[i][j];
				writeColor(ops, t);
			}
			for (int k = 0; k < m; k++) {
				ops.write(0);
			}
		}
	}

	public void writeInt(OutputStream ops, int t) throws Exception {
		int a = (t >> 24) & 0xff;
		int b = (t >> 16) & 0xff;
		int c = (t >> 8) & 0xff;
		int d = t & 0xff;
		ops.write(d);
		ops.write(c);
		ops.write(b);
		ops.write(a);
		//System.out.println(d+" <>"+c+"<>"+b+"<>"+a);
	}

	public void writeColor(OutputStream ops, int t) throws Exception {
		int b = (t >> 16) & 0xff;
		int c = (t >> 8) & 0xff;
		int d = t & 0xff;
		ops.write(d);
		ops.write(c);
		ops.write(b);
	}

	public void writeShort(OutputStream ops, short t) throws Exception {
		int c = (t >> 8) & 0xff;
		int d = t & 0xff;
		ops.write(d);
		ops.write(c);
	}

	// 由于读取的是字节,把读取到的4个byte转化成1个int
	public int changeInt(InputStream ips) throws IOException {
		int t1 = ips.read() & 0xff;
		int t2 = ips.read() & 0xff;
		int t3 = ips.read() & 0xff;
		int t4 = ips.read() & 0xff;
		int num = (t4 << 24) + (t3 << 16) + (t2 << 8) + t1;
		System.out.println(num);
		return num;
	}

	// 24位的图片是1个像素3个字节。
	public int readColor(InputStream ips) throws IOException {
		int b = ips.read() & 0xff;
		int g = ips.read() & 0xff;
		int r = ips.read() & 0xff;
		int c = (r << 16) + (g << 8) + b;
		return c;
	}

	public void actionPerformed(ActionEvent e) {
		// 获得被点击的组件的动作命令
		String command = e.getActionCommand();
		JFileChooser chooser = new JFileChooser();
	if (command.equals("保存")) {
		int t = chooser.showSaveDialog(null);
	         if (t == JFileChooser.APPROVE_OPTION) {
		String path = chooser.getSelectedFile().getAbsolutePath();
		try {
	  FileOutputStream fos = new FileOutputStream(path);
	  DataOutputStream dos = new DataOutputStream(fos);

		savebmpTop(dos);
		savebmpInfo(dos);
		savebmpDate(dos);
		fos.flush();
		fos.close();

     } catch (Exception ef) {
					JOptionPane.showMessageDialog(null, "文件保存失败!!");
	ef.printStackTrace();
	}
      }
} else if (command.equals("打开")) {

		int t = chooser.showOpenDialog(null);
		if (t == JFileChooser.APPROVE_OPTION) {
		String path = chooser.getSelectedFile().getAbsolutePath();
	try {
		FileInputStream fis = new FileInputStream(path);
		DataInputStream dis = new DataInputStream(fis);

	dis.skip(18);
	int width = changeInt(dis);// 跳过不需要的,读取宽度和高度
	int height = changeInt(dis);
	dis.skip(28);
	// 跳过,直接读取位图数据。
	DrawListener.array = new int[height][width];
	int w = 0;
	 if (width * 3 % 4 > 0) {
	   t = 4 - width * 3 % 4;
		}
		for (int i = height - 1; i >= 0; i--) {
			for (int j = 0; j < width; j++) {
		 // 调用自定义方法,得到图片的像素点并保存到int数组中
	int c = readColor(dis);
							DrawListener.array[i][j] = c;
		}
	dis.skip(w);
	}
	fis.close();
	// 刷新界面
	panel.repaint();
	} catch (Exception ef) {
					JOptionPane.showMessageDialog(null, "文件打开失败!!");
	ef.printStackTrace();
	}
    }
  }
 }
}



比较两个方法 :
方法一比较麻烦 再定义一个Color[][]数组 保存屏幕上的点的颜色 BMP格式保存时
color[i][j].getRed()getGreen()getBlue()方法获取屏幕点的颜色分量
方法二 直接利用重绘时定义int[][]数组 32位 后24位是红绿蓝颜色分量
public void writeColor(OutputStream ops, int t) throws Exception {
		int b = (t >> 16) & 0xff;
		int c = (t >> 8) & 0xff;
		int d = t & 0xff;
		ops.write(d);
		ops.write(c);
		ops.write(b);
	}


1.byte转成int为何要&0xff?
第一,oxff默认为整形,二进制位最低8位是1111  1111,前面24位都是0;

第二,&运算: 如果2个bit都是1,则得1,否则得0;

第三,byte的8位和0xff进行&运算后,最低8位中,原来为1的还是1,原来为0的还是0,而0xff其他位都是0,所以&后仍然得0

2.writeInt 方法中传入int值 写出4个int值 依旧是4个字节
因为 write(int a) 是把a转换为byte 而不是byte[] a的高24位将被忽略 只有低8位转成byte写入流

  • BMP.zip (80.3 KB)
  • 下载次数: 1
3
2
分享到:
评论

相关推荐

    BMP格式存储的自制画图板

    总之,通过自制的BMP格式画图板,我们可以学习到图像处理的基础知识,包括BMP文件格式、颜色模式、绘图算法以及图形用户界面的设计。这个过程不仅锻炼了编程技巧,还加深了对计算机图形学的理解。

    BMP文件读取修改保存_画图板_缓冲绘图

    NULL 博文链接:https://yangzhenlin.iteye.com/blog/2068428

    MFC画图板绘图并保存图片导出

    这个应用程序允许用户在画图板上自由绘制,并将这些创作保存为图片格式,例如BMP。我们将围绕以下几个关键知识点展开讨论: 1. **MFC库**:MFC 是微软开发的一个C++类库,它封装了Windows API,为开发者提供了一套...

    自制画板打开和保存BMP格式文件

    总的来说,自制画板打开和保存BMP格式文件涉及到的知识点包括:BMP文件格式的理解与解析,二进制文件操作,图像处理基础,GUI编程,以及事件驱动编程。这个项目既能够加深对图像文件格式的理解,也能锻炼实际编程...

    利用java实现画图板和保存读取BMP格式的图片(一)

    在本篇博文中,我们将探讨如何使用Java编程语言来实现一个简单的画图板应用程序,并学习如何保存和读取BMP(Bitmap)格式的图片。BMP是一种无损图像格式,通常用于存储像素数据,便于程序处理。以下是实现这一功能所...

    画图板,window画图板

    3. 保存与导出:画图完成后,可以保存为BMP、JPEG、PNG等多种格式,便于在其他程序中使用或分享。 总结,Windows画图板以其简单易用和功能丰富的特点,成为了日常生活中进行动态画图的得力助手。无论是儿童学习绘画...

    Android中把bitmap存成BMP格式图片的方法

    首先,Android SDK提供了`Bitmap.compress()`方法来将Bitmap保存为JPEG或PNG格式,但不支持BMP。因此,我们需要自定义一个方法来处理BMP格式的转换。这个过程主要包括以下几个步骤: 1. **获取Bitmap的像素数据**:...

    画图板自定义格式保存

    标题“画图板自定义格式保存”涉及到的是一个与计算机图形处理相关的技术,尤其是与用户交互式的绘图软件或工具的文件保存功能有关。在许多应用程序中,例如Microsoft Paint这样的简单画图工具,用户可以创建自己的...

    画图板_Vc_

    本篇文章将深入探讨一个基于VC++(Visual C++)编写的“画图板”程序,该程序旨在模拟Windows系统自带的画图工具,提供手绘线、绘制简单图形、文字输入、图块拖放、重复撤销、画面缩放以及图片的打开与保存等核心...

    C# 画图板 源码

    9. **保存与加载**: 可能还实现了将画布内容保存到文件(如BMP或PNG格式)以及从文件加载的功能。这涉及到`Bitmap`类的使用,以及文件I/O操作。 10. **代码组织与设计模式**: 良好的源码结构可能包括多个类,每个类...

    仿XP画图板

    【标题】"仿XP画图板"是一款基于JAVA编程语言开发的应用程序,旨在模拟Windows XP操作系统中的经典画图工具。这个项目是为那些希望学习或熟悉GUI(图形用户界面)编程和事件处理的初学者设计的,同时也为用户提供了...

    BMP格式详解 详尽解析BMP图像格式

    BMP 图像格式是与硬件设备无关的图像文件格式,使用非常广泛。它采用位映射存储格式,除了图像深度可选以外,不采用其他任何压缩,因此,BMP 文件所占用的空间很大。BMP 文件的图像深度可选 1bit、4bit、8bit 及 24...

    windows画图板程序源码

    《Windows画图板程序源码解析与探讨》 在计算机编程领域,Windows画图板程序是一种常见的学习和实践窗口应用程序设计的案例。本篇文章将深入剖析"windows画图板程序源码",揭示其背后的原理和技术,以帮助读者更好...

    MFC 简易画图板

    考虑到保存和加载绘图的功能,可以实现`OnFileSave`和`OnFileOpen`函数,将当前的绘图序列化存储到文件中(如BMP或SVG格式),并在打开文件时反序列化并重新绘制。 通过以上步骤,我们可以构建一个功能完备的MFC...

    用C#做的一个画图板工具,可以打开图片在其上画圆,矩形,三角形,直线等,实现看鼠标拖拽画图,可任意选择颜色级画刷类型,可保存为BMP格式

    用C#做的一个画图板工具,可以打开图片在其上画圆,矩形,三角形,直线等,实现看鼠标拖拽画图,可任意选择颜色级画刷类型,可保存为BMP格式

    VC++实现简单的画图板程序

    你需要定义消息映射来处理特定的消息,如WM_PAINT消息用于窗口重绘,WM_LBUTTONDOWN和WM_LBUTTONUP用于处理鼠标点击。 7. **文件I/O**:为了实现打开和保存BMP文件的功能,你需要使用CFile类或者更高级的fstream来...

    仿windows画图板

    【标题】"仿windows画图板"所涉及的知识点主要集中在使用Visual Basic(VB)编程语言来创建一个图形用户界面(GUI),模仿Windows操作系统自带的“画图”应用程序。这个项目旨在提供一个基本的绘图工具,让用户可以...

Global site tag (gtag.js) - Google Analytics