经常写Markdown的小伙伴都会遇到一个问题,那就是图片存储问题。Markdown本身并不支持图片内置,传统用法一般通过外链引用本机或网络上的图片。但是当文章在网络上发表时,存储图片就成了个问题,虽然可以通过图床上传,但是操作麻烦。
好在Markdown支持通过base641编码数据渲染图片,这里根据这个功能编写了一个小工具Image2Base64,可以方便地在图片文件和base64编码之间转换。
1. 软件功能
下面就是这个软件的图形界面,这个截图就是通过base64渲染的。
软件界面左侧窗口是图片显示窗口,可以通过拖放打开图片,支持bmp、png、jpg等主流格式的图片(暂不支持gif动图显示),支持右键菜单,支持图片放大缩小和重置等基本操作。
左侧窗口下方的Paste
按钮可以将剪贴板中的图像数据读取并显示出来,方便通过截图软件和剪贴板交互读取图像数据。
右侧窗口是文本显示窗口,如果转换成功这里会图片对应的base64编码并显示出来。下方的Markdown复选框用于添加Markdown的图片语法标签,点击Copy
按钮后可以将文本数据复制到剪贴板,之后就可以直接在MD文件中粘贴了。
值得注意的是,Copy
按钮左侧的组合框可以选择base64对应的图片格式。没错,base64也是有对应格式区分的,base64本质上是将二进制编码的文本化,因此不同图片原始格式的大小所导致的base64编码长度自然不一样,甚至图片的复杂程度和压缩比等因素对base64编码长度都有影响。
右侧窗口下方的Save as
按钮支持将图片文件以原始格式或base64编码(txt文件)存储。通过中间的两个方向按钮可以实现将图片转换成base64编码,或者将base64编码转换成图片。需要注意的是,将base64编码转换成图片时,请不要包含markdown的语法标签,否则会报错。
该软件经过测试,可以使用png格式base64编码,通过markdown语法标签在marktext和joplin上渲染图片,其他格式不保证能成功,取决于markdown编辑器的渲染引擎。对于一般屏幕截图,推荐采用png格式。
2. 代码解析
2.1 C++实现
该软件基于Qt6实现,图片格式编码、渲染以及base64转换都是通过Qt实现的。
图片显示通过QLabel实现,重载了QLabel类2,并做了一些调整。
photolabel.h
:
#ifndef PHOTOLABEL_H
#define PHOTOLABEL_H
#include <QObject>
#include <QLabel>
#include <QMenu>
#include <QDragEnterEvent>
#include <QDropEvent>
class PhotoLabel : public QLabel
{
Q_OBJECT
public:
explicit PhotoLabel(QWidget* parent = nullptr);
void openFile(QString); //打开图片文件
void clearShow(); //清空显示
void setImage(QImage&); //设置图片
void openAction(); //调用打开文件对话框
void pasteAction(); //粘贴来自剪贴板的图片
const QImage& getImage(); //调用存储的图片数据
signals:
// 图片加载成功信号
void imageLoadSuccess();
protected:
void contextMenuEvent(QContextMenuEvent* event) override; //右键菜单
void paintEvent(QPaintEvent* event) override; //QPaint画图
void wheelEvent(QWheelEvent* event) override; //鼠标滚轮滚动
void mousePressEvent(QMouseEvent* event) override; //鼠标摁下
void mouseMoveEvent(QMouseEvent* event) override; //鼠标松开
void mouseReleaseEvent(QMouseEvent* event) override; //鼠标发射事件
//拖放文件
void dragEnterEvent(QDragEnterEvent* event) override;
void dragMoveEvent(QDragMoveEvent* event) override;
void dropEvent(QDropEvent* event) override;
private slots:
void initWidget(); //初始化
void onSelectImage(); //选择打开图片
void onPasteImage(); //选择粘贴图片
void onZoomInImage(); //图片放大
void onZoomOutImage(); //图片缩小
void onPresetImage(); //图片还原
private:
QImage m_image; //显示的图片
qreal m_zoomValue = 1.0; //鼠标缩放值
int m_xPtInterval = 0; //平移X轴的值
int m_yPtInterval = 0; //平移Y轴的值
QPoint m_oldPos; //旧的鼠标位置
bool m_pressed = false; //鼠标是否被摁压
QString m_localFileName; //文件名称
QMenu* m_menu; //右键菜单
};
#endif // PHOTOLABEL_H
photolabel.cpp
:
#include "photolabel.h"
#include <QPainter>
#include <QDebug>
#include <QWheelEvent>
#include <QFileDialog>
#include <QClipboard>
#include <QApplication>
#include <QMimeData>
#include <QFileInfo>
#include <QMessageBox>
PhotoLabel::PhotoLabel(QWidget* parent) :QLabel(parent)
{
initWidget();
}
void PhotoLabel::initWidget()
{
//初始化右键菜单
m_menu = new QMenu(this);
QAction* loadImage = new QAction;
loadImage->setText(tr("&Open new..."));
connect(loadImage, &QAction::triggered, this, &PhotoLabel::onSelectImage);
m_menu->addAction(loadImage);
QAction* pasteImage = new QAction;
pasteImage->setText(tr("&Paste image"));
connect(pasteImage, &QAction::triggered, this, &PhotoLabel::onPasteImage);
m_menu->addAction(pasteImage);
m_menu->addSeparator();
QAction* zoomInAction = new QAction;
zoomInAction->setText(tr("Zoom in &+"));
connect(zoomInAction, &QAction::triggered, this, &PhotoLabel::onZoomInImage);
m_menu->addAction(zoomInAction);
QAction* zoomOutAction = new QAction;
zoomOutAction->setText(tr("Zoom out &-"));
connect(zoomOutAction, &QAction::triggered, this, &PhotoLabel::onZoomOutImage);
m_menu->addAction(zoomOutAction);
QAction* presetAction = new QAction;
presetAction->setText(tr("&Reset"));
connect(presetAction, &QAction::triggered, this, &PhotoLabel::onPresetImage);
m_menu->addAction(presetAction);
m_menu->addSeparator();
/*
QAction *clearAction = new QAction;
clearAction->setText("Clear");
connect(clearAction, &QAction::triggered, this, &PhotoLabel::clearShow);
m_menu->addAction(clearAction);
*/
}
void PhotoLabel::openFile(QString path)
{
if (path.isEmpty())
{
return;
}
if (!m_image.load(path))
{
QMessageBox::warning(this, tr("Error"), tr("Cannot load file!"));
return;
}
m_zoomValue = 1.0;
m_xPtInterval = 0;
m_yPtInterval = 0;
m_localFileName = path;
emit this->imageLoadSuccess();
update();
}
void PhotoLabel::clearShow()
{
m_localFileName = "";
m_image = QImage();
this->clear();
}
void PhotoLabel::setImage(QImage& img)
{
if (img.isNull())
{
return;
}
m_zoomValue = 1.0;
m_xPtInterval = 0;
m_yPtInterval = 0;
m_localFileName = "";
m_image = img.copy(0, 0, img.width(), img.height());
emit imageLoadSuccess();
update();
}
void PhotoLabel::openAction()
{
this->onSelectImage();
}
void PhotoLabel::pasteAction()
{
this->onPasteImage();
}
const QImage& PhotoLabel::getImage()
{
return m_image;
}
void PhotoLabel::paintEvent(QPaintEvent* event)
{
if (m_image.isNull())
return QWidget::paintEvent(event);
QPainter painter(this);
// 根据窗口计算应该显示的图片的大小
int width = qMin(m_image.width(), this->width());
int height = int(width * 1.0 / (m_image.width() * 1.0 / m_image.height()));
height = qMin(height, this->height());
width = int(height * 1.0 * (m_image.width() * 1.0 / m_image.height()));
// 平移
painter.translate(this->width() / 2 + m_xPtInterval, this->height() / 2 + m_yPtInterval);
// 缩放
painter.scale(m_zoomValue, m_zoomValue);
// 绘制图像
QRect picRect(-width / 2, -height / 2, width, height);
painter.drawImage(picRect, m_image);
QWidget::paintEvent(event);
}
void PhotoLabel::wheelEvent(QWheelEvent* event)
{
int value = event->angleDelta().y() / 15;
if (value < 0) //放大
onZoomInImage();
else //缩小
onZoomOutImage();
update();
}
void PhotoLabel::mousePressEvent(QMouseEvent* event)
{
m_oldPos = event->pos();
m_pressed = true;
this->setCursor(Qt::ClosedHandCursor); //设置鼠标样式
}
void PhotoLabel::mouseMoveEvent(QMouseEvent* event)
{
if (!m_pressed)
return QWidget::mouseMoveEvent(event);
QPoint pos = event->pos();
int xPtInterval = pos.x() - m_oldPos.x();
int yPtInterval = pos.y() - m_oldPos.y();
m_xPtInterval += xPtInterval;
m_yPtInterval += yPtInterval;
m_oldPos = pos;
update();
}
void PhotoLabel::mouseReleaseEvent(QMouseEvent*/*event*/)
{
m_pressed = false;
this->setCursor(Qt::ArrowCursor); //设置鼠标样式
}
void PhotoLabel::dragEnterEvent(QDragEnterEvent* event)
{
if (event->mimeData()->hasUrls())
{
event->acceptProposedAction();
}
else
{
event->ignore();
}
}
void PhotoLabel::dragMoveEvent(QDragMoveEvent* event)
{
}
void PhotoLabel::dropEvent(QDropEvent* event)
{
QList<QUrl> urls = event->mimeData()->urls();
if (urls.empty())
return;
QString fileName = urls.first().toLocalFile();
QFileInfo info(fileName);
if (!info.isFile())
return;
this->openFile(fileName);
}
void PhotoLabel::contextMenuEvent(QContextMenuEvent* event)
{
QPoint pos = event->pos();
pos = this->mapToGlobal(pos);
m_menu->exec(pos);
}
void PhotoLabel::onSelectImage()
{
QString path = QFileDialog::getOpenFileName(this, tr("Open a image file"), "./", tr("Images (*.bmp *.png *.jpg *.jpeg *.gif *.tiff)\nAll files (*.*)"));
if (path.isEmpty())
return;
openFile(path);
}
void PhotoLabel::onPasteImage()
{
QClipboard* clipboard = QApplication::clipboard();
QImage img = clipboard->image();
if (img.isNull())
{
return;
}
this->setImage(img);
}
void PhotoLabel::onZoomInImage()
{
m_zoomValue += 0.05;
update();
}
void PhotoLabel::onZoomOutImage()
{
m_zoomValue -= 0.05;
if (m_zoomValue <= 0)
{
m_zoomValue = 0.05;
return;
}
update();
}
void PhotoLabel::onPresetImage()
{
m_zoomValue = 1.0;
m_xPtInterval = 0;
m_yPtInterval = 0;
update();
}
base64和图片转换的代码放在窗口控件的槽函数中。
widget.cpp
:
void Widget::updateCode() //图片数据转换成base64编码
{
QImage image = ui->viewer->getImage();
if (image.isNull())
{
QMessageBox::warning(this, tr("Error"), tr("Please load an image file!"));
return;
}
QByteArray ba;
QBuffer buf(&ba);
image.save(&buf, format.toStdString().c_str());
QByteArray md5 = QCryptographicHash::hash(ba, QCryptographicHash::Md5);
QString strMd5 = md5.toHex();
QString head_md = QString::fromUtf8("");
QString prefix = QString::fromUtf8("data:image/%1;base64,").arg(format);
QString code = QString::fromUtf8(ba.toBase64());
if (ui->checkBox->isChecked())
{
ui->textEdit->setText(head_md.arg(strMd5).arg(prefix + code));
}
else
{
ui->textEdit->setText(prefix + code);
}
buf.close();
int num = ui->textEdit->toPlainText().length();
ui->label->setText(tr("Length : ") + QString::number(num));
}
void Widget::updateImage() // base64编码转换成图片数据
{
QString p_b = ui->textEdit->toPlainText();
if (p_b.isEmpty())
{
return;
}
if (p_b.contains(QRegularExpression("data:image[/a-z]*;base64,")))
{
// 清除base64编码的html标签
p_b = p_b.remove(QRegularExpression("data:image[/a-z]*;base64,"));
}
QImage image;
if (!image.loadFromData(QByteArray::fromBase64(p_b.toLocal8Bit())))
{
QMessageBox::warning(this, tr("Error"), tr("Fail to decrypt codes!"));
return;
}
ui->viewer->setImage(image);
}
2.2 Python实现
同样的,该软件也提供了基于Python的实现,同样通过重载QLabel实现图片显示。
photolabel.py
:
# This Python file uses the following encoding: utf-8
import sys
from PySide6.QtCore import (Qt, qDebug, QFileInfo, QMimeData, QRect, QPoint)
from PySide6.QtGui import (QAction, QImage, QAction, QDragEnterEvent, QDragMoveEvent,
QDropEvent, QContextMenuEvent, QPaintEvent, QMouseEvent,
QWheelEvent, QPainter, QClipboard, QCursor)
from PySide6.QtWidgets import (QApplication, QLabel, QMenu, QMessageBox, QFileDialog)
class PhotoLabel(QLabel):
def __init__(self, parent):
super().__init__(parent)
self.m_image = QImage() # 显示的图片
self.m_zoomValue = 1.0 # 鼠标缩放值
self.m_xPtInterval = 0 # 平移X轴的值
self.m_yPtInterval = 0 # 平移Y轴的值
self.m_oldPos = QPoint() # 旧的鼠标位置
self.m_pressed = False # 鼠标是否被摁压
self.m_localFileName = None # 文件名称
self.m_menu = None
self.initial_widget()
def initial_widget(self):
self.m_menu = QMenu(self)
load_image = QAction(u"&Open new...", self)
load_image.triggered.connect(self.on_select_image)
self.m_menu.addAction(load_image)
paste_image = QAction(u"&Paste image", self)
paste_image.triggered.connect(self.on_paste_image)
self.m_menu.addAction(paste_image)
self.m_menu.addSeparator()
zoom_in_action = QAction(u"Zoom in &+", self)
zoom_in_action.triggered.connect(self.on_zoom_in_image)
self.m_menu.addAction(zoom_in_action)
zoom_out_action = QAction(u"Zoom out &-", self)
zoom_out_action.triggered.connect(self.on_zoom_out_image)
self.m_menu.addAction(zoom_out_action)
self.m_menu.addSeparator()
preset_action = QAction(u"&Reset", self)
preset_action.triggered.connect(self.on_preset_image)
self.m_menu.addAction(preset_action)
self.m_menu.addSeparator()
# clear_action = QAction(u"&Reset", self)
# clear_action.triggered.connect(self.clear_show)
# self.m_menu.addAction(clear_action)
def open_file(self, path: str): # 打开图片文件
if path is None:
return
if self.m_image.load(path) is False:
QMessageBox.warning(self, "Error", "Cannot load file!")
return
self.m_zoomValue = 1.0
self.m_xPtInterval = 0
self.m_yPtInterval = 0
self.m_localFileName = path
self.update()
def clear_show(self): # 清空显示
self.m_localFileName = None
self.m_image = QImage()
self.clear()
def set_image(self, image: QImage): # 设置图片
if image is None:
return
self.m_zoomValue = 1.0
self.m_xPtInterval = 0
self.m_yPtInterval = 0
self.m_localFileName = None
self.m_image = image.copy(0, 0, image.width(), image.height())
self.update()
def open_action(self): # 调用打开文件对话框
self.on_select_image()
def paste_action(self): # 粘贴来自剪贴板的图片
self.on_paste_image()
def get_image(self) -> QImage: # 调用存储的图片数据
return self.m_image
def contextMenuEvent(self, event: QContextMenuEvent): # 右键菜单
pos = event.pos()
pos = self.mapToGlobal(pos)
self.m_menu.exec(pos)
def paintEvent(self, event: QPaintEvent): # QPaint画图
if self.m_image.isNull():
# super().paintEvent(event)
return
painter = QPainter(self)
# 根据窗口计算应该显示的图片的大小
width = min(self.m_image.width(), self.width())
height = int(width * 1.0 / (self.m_image.width() * 1.0 / self.m_image.height()))
height = min(height, self.height())
width = int(height * 1.0 * (self.m_image.width() * 1.0 / self.m_image.height()))
# 平移
painter.translate(self.width() / 2 + self.m_xPtInterval, self.height() / 2 + self.m_yPtInterval)
# 缩放
painter.scale(self.m_zoomValue, self.m_zoomValue)
# 绘制图像
pic_rect = QRect(int(-width / 2), int(-height / 2), width, height)
painter.drawImage(pic_rect, self.m_image)
# super().paintEvent(event)
def wheelEvent(self, event: QWheelEvent): # 鼠标滚轮滚动
value = int(event.angleDelta().y() / 15)
if value < 0: # 放大
self.on_zoom_in_image()
else: # 缩小
self.on_zoom_out_image()
self.update()
def mousePressEvent(self, event: QMouseEvent): # 鼠标摁下
self.m_oldPos = event.pos()
self.m_pressed = True
self.setCursor(Qt.ClosedHandCursor) # 设置鼠标样式
def mouseMoveEvent(self, event: QMouseEvent): # 鼠标松开
if self.m_pressed is False:
# super().mouseMoveEvent(event)
return
pos = event.pos()
xp = pos.x() - self.m_oldPos.x()
yp = pos.y() - self.m_oldPos.y()
self.m_xPtInterval += xp
self.m_yPtInterval += yp
self.m_oldPos = pos
self.update()
def mouseReleaseEvent(self, event: QMouseEvent): # 鼠标发射事件
self.m_pressed = False
self.setCursor(Qt.ArrowCursor) # 设置鼠标样式
# 拖放文件
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event: QDragMoveEvent):
pass
def dropEvent(self, event: QDropEvent):
urls = event.mimeData().urls()
if urls is None:
return
file_name = urls[0].toLocalFile()
info = QFileInfo(file_name)
if info.isFile() is False:
return
self.open_file(file_name)
def on_select_image(self): # 选择打开图片
path, _ = QFileDialog.getOpenFileName(self,
"Open a image file", "./",
"Images (*.bmp *.png *.jpg *.jpeg *.gif *.tiff)\nAll files (*.*)")
if path is None:
return
info = QFileInfo(path)
if info.isFile() is False:
return
self.open_file(path)
def on_paste_image(self): # 选择粘贴图片
clipboard = QApplication.clipboard()
img = clipboard.image()
if img.isNull():
return
self.set_image(img)
def on_zoom_in_image(self): # 图片放大
self.m_zoomValue += 0.05
self.update()
def on_zoom_out_image(self): # 图片缩小
self.m_zoomValue -= 0.05
if self.m_zoomValue <= 0:
self.m_zoomValue = 0.05
return
self.update()
def on_preset_image(self): # 图片还原
self.m_zoomValue = 1.0
self.m_xPtInterval = 0
self.m_yPtInterval = 0
self.update()
if __name__ == "__main__":
pass
值得注意的是,python实现代码中,二进制数据到base64的转换是通过ptyhon的str函数完成的,因此输出字符串会包含b'...'
的标签,需要通过字符串切片去除该标签。
widget.py
:
def update_code(self): #图片→base64
image = self.ui.viewLabel.get_image()
if image.isNull():
QMessageBox.warning(self, "Error", "Please load an image file!")
return
ba = QByteArray()
buf = QBuffer(ba)
image.save(buf, self.m_format)
md5 = QCryptographicHash.hash(ba, QCryptographicHash.Md5)
strMd5 = str(md5.toHex())[2:-1]
prefix = "data:image/{};base64,".format(self.m_format)
code = str(ba.toBase64())[2:-1]
if self.ui.checkBox.isChecked():
self.ui.textEdit.setText("".format(strMd5, prefix + code))
else:
self.ui.textEdit.setText(prefix + code)
buf.close()
num = len(self.ui.textEdit.toPlainText())
self.ui.lengthLabel.setText("Length : " + str(num))
def update_image(self): #base64→图片
p_b = self.ui.textEdit.toPlainText()
if len(p_b) == 0:
return
if re.match("data:image[/a-z]*;base64,", p_b):
p_b = re.sub("data:image[/a-z]*;base64,", "", p_b, count=1)
image = QImage()
if image.loadFromData(QByteArray.fromBase64(p_b.encode())) is False:
QMessageBox.warning(self, "Error", "Fail to decrypt codes!")
return
self.ui.viewLabel.set_image(image)
UI均通过QtDesigner实现,无论C++实现还是Python实现,软件界面效果均一致。