可以直接看主要代碼實作
doc作為模闆檔案生成指定格式的doc檔案
實作邏輯
1、把作為模闆的doc檔案另存為xml檔案
2、凡是需要填充的資料用${xxxx}替代
3、利用Template類将資料填充到模闆并生成檔案
代碼:
/**
* 将資料以特定模闆格式輸出到word文檔(目前僅支援輸出doc檔案,隻能通過代碼修改文字内容)
* @param data 輸入模闆資料
* @param templatePath 模闆存放路徑
* @param templateName 模闆名稱(XXX.xml,由doc/docx文檔轉換而成)
* @param exFilePath 輸出檔案路徑
* @param exFileName 輸出檔案名稱(XXX.doc)
* @return
*/
public static boolean createDoc(Map<String,Object> data,String templatePath,String templateName,String exFilePath,String exFileName) {
boolean result = false;
Writer out = null;
URL basePath = WordUtils.class.getClassLoader().getResource("");//擷取類檔案所在根目錄,注意是編譯後的class檔案目錄
try {
Configuration configuration = new Configuration();
configuration.setDefaultEncoding("UTF-8");
configuration.setDirectoryForTemplateLoading(new File(basePath.getPath() + templatePath));
Template t = configuration.getTemplate(templateName); //擷取模闆檔案
File file = new File(basePath.getPath() + exFilePath);//生成生成檔案所在目錄
if (!file.exists() && !file.isDirectory()) {
file.mkdirs();
}
File outFile = new File(basePath.getPath() + exFilePath + separator + exFileName); //導出檔案生成
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outFile)));
t.process(data, out); //将填充資料填入模闆檔案并輸出到目标檔案
result = true;
} catch (Exception e) {
e.printStackTrace();
} finally{
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return result;
}
docx作為模闆檔案生成自定義格式的doc/docx檔案
實作邏輯
1、把doc/docx文檔修改為ZIP格式(直接修改字尾)
2、取出
word/document.xml(主要内容)
[Content_Types].xml、\word_rels\document.xml.rels(圖檔配置)
word/header1.xml(頁眉)
……
3、根據需要替換的内容修改以上檔案,具體見替換檔案說明
4、以zip檔案的形式處理模闆檔案(doc/docx) ,周遊該zip,将zip内除了media目錄下的檔案(圖檔另外處理)和替換檔案(步驟3内檔案)的每個檔案輸出到指定檔案(doc/docx,以zip流的形式輸入)
String xmlDocumentXmlRelsComment = FreeMarkUtils.getFreemarkerContent(dataMap, xmlDocumentXmlRels, templatePath);
ByteArrayInputStream documentXmlRelsInput = new ByteArrayInputStream(xmlDocumentXmlRelsComment.getBytes());//替換的xml
ZipOutputStream zipout = new ZipOutputStream(new FileOutputStream("D:/workplace/springBootWorkplace/myDemo/target/classes/templates/out.docx"));
ZipFile zipFile = new ZipFile("D:/workplace/springBootWorkplace/myDemo/target/classes/templates/docTemplates.docx");//以zip檔案的形式處理docx檔案
Enumeration<? extends ZipEntry> zipEntrys = zipFile.entries();//周遊zip檔案内的所有檔案
int len = -1;
byte[] buffer = new byte[1024];
while (zipEntrys.hasMoreElements()) {
ZipEntry next = zipEntrys.nextElement();//以枚舉方式擷取檔案
InputStream is = zipFile.getInputStream(next);//用于檔案輸出
zipout.putNextEntry(new ZipEntry(next.getName()));//開始寫入,設定檔案名稱
if (documentXmlRelsInput != null) {
while ((len = documentXmlRelsInput.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
documentXmlRelsInput.close();
}else{
while ((len = is.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
}
is.close();
}
zipout.close();
zipFile.close();
5、将替換檔案和圖檔檔案(word/media)以zip流形式輸入到指定檔案
6、關閉流
word轉換zip後替換檔案說明
1、\word\document.xml
用來存在word文檔的主要資料資訊
2、\word_rels\document.xml.rels
用來存在word文檔的主要資料配置 包括圖檔的指向
<Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.jpeg"/>
rId7:唯一辨別,image1.jpeg:圖檔名稱
與對應的document.xml
<wp:docPr id="2" name="圖檔 2" descr="D:\阿裡工作\裝修\裝修\北歐1\1.jpg"/>
id:唯一辨別,name:辨別,descr:圖檔路徑
<a:blip r:embed="rId7">
r:embed對應document.xml.rels的Id
<w:p w:rsidR="00EC2516" w:rsidRPr="00EC2516" w:rsidRDefault="00EC2516" w:rsidP="00EC2516">
<w:r w:rsidRPr="00EC2516">
<w:rPr>
<w:noProof/>
</w:rPr>
<w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="5274310" cy="7024797"/>
<wp:effectExtent l="0" t="0" r="2540" b="5080"/>
<wp:docPr id="2" name="圖檔 2" descr="D:\阿裡工作\裝修\裝修\北歐1\1.jpg"/>
<wp:cNvGraphicFramePr>
<a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"></a:graphicFrameLocks>
</wp:cNvGraphicFramePr>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:nvPicPr>
<pic:cNvPr id="0" name="Picture 2" descr="D:\阿裡工作\裝修\裝修\北歐1\1.jpg"/>
<pic:cNvPicPr>
<a:picLocks noChangeAspect="1" noChangeArrowheads="1"/>
</pic:cNvPicPr>
</pic:nvPicPr>
<pic:blipFill>
<a:blip r:embed="rId7">
<a:extLst>
<a:ext uri="{28A0092B-C50C-407E-A947-70E740481C1C}">
<a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"></a14:useLocalDpi>
</a:ext>
</a:extLst>
</a:blip>
<a:srcRect/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</pic:blipFill>
<pic:spPr bwMode="auto">
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="5274310" cy="7024797"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
<a:noFill/>
<a:ln>
<a:noFill/>
</a:ln>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>
3、\word\header1.xml
用來配置docx文檔的頁眉檔案
頁眉:Word文檔模闆測試ymdhis
<w:p w:rsidR="00DF53B8" w:rsidRDefault="00DF53B8" w:rsidP="00DF53B8">
<w:pPr>
<w:pStyle w:val="a3"/>
</w:pPr>
<w:r>
<w:t>Word</w:t>
</w:r>
<w:r>
<w:t>文檔模闆測試</w:t>
</w:r>
<w:r w:rsidR="00CC7E39">
<w:t>ymdhis</w:t>
</w:r>
</w:p>
4、[Content_Types].xml
用來配置 docx文檔中所插入圖檔的類型 如 png、jpeg、jpg等
,XX的格式要與插入檔案的格式一緻,每種類别加一條記錄
<Default Extension="jpeg" ContentType="image/jpeg"/>
代碼
pom.xml
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.23</version>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.dom4j</groupId>
<artifactId>dom4j</artifactId>
<version>2.1.1</version>
</dependency>
/**
* 擷取目前日期的字元串(毫秒) 如
* @return
*/
public static String getCurrentTime_yyyyMMddHHmmssSSS(){
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
return sdf.format(new Date());
}
/**
* 将模闆填充完資料以字元串形式輸出
* @param dataMap 參數
* @param templateName 模闆名稱
* @param temp_path 模闆路徑 classes下的路徑 如果 classes/templates 傳入 /templates即可
* @return
*/
public static String getFreemarkerContent(Map dataMap, String templateName, String temp_path) {
String result = "";
try {
//建立配置執行個體
Configuration configuration = new Configuration();
//設定編碼
configuration.setDefaultEncoding("UTF-8");
//ftl模闆檔案統一放至 com.lun.template 包下面
// configuration.setDirectoryForTemplateLoading(new File("D:/idea_workspace/alarm/alarm/src/main/resources/template/"));
configuration.setClassForTemplateLoading(FreeMarkUtils.class, temp_path);
//擷取模闆
Template template = configuration.getTemplate(templateName);
StringWriter swriter = new StringWriter();
//将填充資料填入模闆檔案并輸出到目标檔案/流
template.process(dataMap, swriter);
result = swriter.toString();
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 将模闆填充完資料以流形式輸出
* @param dataMap 參數
* @param templateName 模闆名稱
* @param tempPath 模闆路徑 classes下的路徑 如果 classes/templates 傳入 /templates即可
* @return
*/
public static ByteArrayInputStream getFreemarkerContentInputStream(Map dataMap, String templateName, String tempPath) {
ByteArrayInputStream in = null;
try {
//建立配置執行個體
Configuration configuration = new Configuration();
//設定編碼
configuration.setDefaultEncoding("UTF-8");
//ftl模闆檔案統一放至 com.lun.template 包下面
// configuration.setDirectoryForTemplateLoading(new File("D:/idea_workspace/alarm/alarm/src/main/resources/template/"));
configuration.setClassForTemplateLoading(FreeMarkUtils.class, tempPath);
//擷取模闆
Template template = configuration.getTemplate(templateName);
StringWriter swriter = new StringWriter();
//生成檔案
template.process(dataMap, swriter);
//String result = swriter.toString();
in = new ByteArrayInputStream(swriter.toString().getBytes());
} catch (Exception e) {
e.printStackTrace();
}
return in;
}
**
主要代碼實作
**
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import freemarker.template.Configuration;
import freemarker.template.Template;
import java.io.*;
import java.net.URL;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* 注意該方法所在工程是springboot結構,模闆放置于\src\main\resources\templates\下,其他工程結構要将讀取路徑對應修改
* frameMark:${title}
* @author Administrator
*
*/
public class WordUtils {
private final static String separator = File.separator;
private final static String suffix_docx = "docx";
private final static String suffix_doc = "doc";
/**
* 将資料以特定模闆格式輸出到word文檔(目前僅支援輸出doc檔案,隻能通過代碼修改文字内容)
* @param data 輸入模闆資料
* @param templatePath 模闆存放路徑
* @param templateName 模闆名稱(XXX.xml,由doc/docx文檔轉換而成)
* @param exFilePath 輸出檔案路徑
* @param exFileName 輸出檔案名稱(XXX.doc)
* @return
*/
public static boolean createDoc(Map<String,Object> data,String templatePath,String templateName,String exFilePath,String exFileName) {
boolean result = false;
Writer out = null;
URL basePath = WordUtils.class.getClassLoader().getResource("");//擷取類檔案所在根目錄,注意是編譯後的class檔案目錄
try {
Configuration configuration = new Configuration();
configuration.setDefaultEncoding("UTF-8");
configuration.setDirectoryForTemplateLoading(new File(basePath.getPath() + templatePath));
Template t = configuration.getTemplate(templateName); //擷取模闆檔案
File file = new File(basePath.getPath() + exFilePath);//生成生成檔案所在目錄
if (!file.exists() && !file.isDirectory()) {
file.mkdirs();
}
File outFile = new File(basePath.getPath() + exFilePath + separator + exFileName); //導出檔案生成
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outFile)));
t.process(data, out); //将填充資料填入模闆檔案并輸出到目标檔案
result = true;
} catch (Exception e) {
e.printStackTrace();
} finally{
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return result;
}
/**
* 以docx為模闆生成doc/docx檔案
* 模闆檔案的配置檔案由docx轉換成zip擷取,docx文檔生成工具類 (改變字尾名即可)
* 在使用制作模闆的過程中如果模闆中有圖檔那就保留圖檔,修改[Content_Types].xml和document.xml.rels文檔,如果模闆中沒有圖檔 則不需要設定[Content_Types].xml和document.xml.rels
* 由于word模闆的個性化 是以 每次做模闆都要重新覆寫原來的模闆
* @param dataMap 參數資料
* @param docxTemplateFile docx模主機闆名稱(注意模闆檔案隻能為docx,doc不能被作為zip讀取)
* @param xmlDocument docx中document.xml模闆檔案 用來存放word文檔的主要資料資訊
* @param xmlDocumentXmlRels docx中document.xml.rels 模闆檔案 用來存放word文檔的主要資料配置 包括圖檔的指向
* @param xmlContentTypes docx中 [Content_Types].xml 模闆檔案 用來配置 docx文檔中所插入圖檔的類型 如 png、jpeg、jpg等
* @param xmlHeader docx中 header1.xml 模闆檔案 用來配置docx文檔的頁眉檔案
* @param templatePath 模闆存放路徑 如 /templates/
* @param outputFileTempPath 所生成的docx檔案的臨時路徑檔案夾 如果 temp/20180914051811/
* @param outputFileName 所生成的docx檔案名稱 如 xxx.docx 或 xxx.doc
* @throws Exception
*/
public static void createDocx(Map<String, Object> dataMap, String docxTemplateFile, String xmlDocument, String xmlDocumentXmlRels,
String xmlContentTypes, String xmlHeader, String templatePath,
String outputFileTempPath, String outputFileName) throws Exception {
URL basePath = WordUtils.class.getClassLoader().getResource("");//擷取類檔案所在根目錄,注意是編譯後的class檔案目錄
String realTemplatePath = basePath.getPath() + templatePath;
//臨時檔案産出的路徑
String outputPath = basePath.getPath() + outputFileTempPath;
try {
//================================擷取 document.xml.rels 輸入流================================
String xmlDocumentXmlRelsComment = FreeMarkUtils.getFreemarkerContent(dataMap, xmlDocumentXmlRels, templatePath);
ByteArrayInputStream documentXmlRelsInput = new ByteArrayInputStream(xmlDocumentXmlRelsComment.getBytes());
//================================擷取 header1.xml 輸入流================================
ByteArrayInputStream headerInput = FreeMarkUtils.getFreemarkerContentInputStream(dataMap, xmlHeader, templatePath);
//================================擷取 [Content_Types].xml 輸入流================================
ByteArrayInputStream contentTypesInput = FreeMarkUtils.getFreemarkerContentInputStream(dataMap, xmlContentTypes, templatePath);
//讀取 document.xml.rels 檔案 并擷取rId 與 圖檔的關系 (如果沒有圖檔 此檔案不用編輯直接讀取就行了)
Document document = DocumentHelper.parseText(xmlDocumentXmlRelsComment);
Element rootElt = document.getRootElement(); // 擷取根節點
Iterator iter = rootElt.elementIterator();// 擷取根節點下的子節點head
List<Map<String, String>> picList = (List<Map<String, String>>) dataMap.get("picList");
// 周遊Relationships節點,擷取Target類型為media的節點(該節點由frameMark生成),将其Id值映射到picList的子項,對應rId
while (iter.hasNext()) {
Element recordEle = (Element) iter.next();
String id = recordEle.attribute("Id").getData().toString();
String target = recordEle.attribute("Target").getData().toString();
if (target.indexOf("media") == 0) {
for (Map<String, String> picMap : picList) {
if (target.endsWith(picMap.get("name"))) {
picMap.put("rId", id);
}
}
}
}
dataMap.put("picList", picList);//覆寫原來的picList,主要為了關聯 document.xml.rels内的圖檔配置聲明;
//================================擷取 document.xml 輸入流================================
ByteArrayInputStream documentInput = FreeMarkUtils.getFreemarkerContentInputStream(dataMap, xmlDocument, templatePath);
File docxFile = new File(realTemplatePath + separator + docxTemplateFile);
if (!docxFile.exists()) {
docxFile.createNewFile();
}
ZipFile zipFile = new ZipFile(docxFile);//模闆檔案隻能為docx類型,doc類型不能被作為zip讀取
Enumeration<? extends ZipEntry> zipEntrys = zipFile.entries();//用于周遊壓縮檔案内的每個檔案
File tempPath = new File(outputPath);
//如果輸出目标檔案夾不存在,則建立
if (!tempPath.exists()) {
tempPath.mkdirs();
}
ZipOutputStream zipout = new ZipOutputStream(new FileOutputStream(outputPath + outputFileName));
//================================覆寫文檔-begin================================
//邏輯說明:将模闆檔案(doc/docx)以zip形式處理,周遊該zip,将zip内除了media目錄下的檔案(圖檔另外處理)的每個檔案輸入到指定檔案(doc/docx,以zip形式輸入),其中[Content_Types].xml、document.xml.rels、word/document.xml、word/header1.xml用frameMark生成的檔案替代
int len = -1;
byte[] buffer = new byte[1024];
while (zipEntrys.hasMoreElements()) {
ZipEntry next = zipEntrys.nextElement();//以枚舉方式擷取檔案
InputStream is = zipFile.getInputStream(next);
if (next.toString().indexOf("media") < 0) {
// 把輸入流的檔案傳到輸出流中 如果是word/document.xml由我們輸入
zipout.putNextEntry(new ZipEntry(next.getName()));//開始寫入,設定檔案名稱
// System.out.println("next.getName()>>>" + next.getName() + " next.isDirectory()>>>" + next.isDirectory());
//寫入圖檔配置類型
if (next.getName().equals("[Content_Types].xml")) {
if (contentTypesInput != null) {
while ((len = contentTypesInput.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
contentTypesInput.close();
}
} else if (next.getName().indexOf("document.xml.rels") > 0) {
//寫入填充資料後的主資料配置資訊
if (documentXmlRelsInput != null) {
while ((len = documentXmlRelsInput.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
documentXmlRelsInput.close();
}
} else if ("word/document.xml".equals(next.getName())) {
//寫入填充資料後的主資料資訊
if (documentInput != null) {
while ((len = documentInput.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
documentInput.close();
}
} else if ("word/header1.xml".equals(next.getName())) {
//寫入填充資料後的頁眉資訊
if (headerInput != null) {
while ((len = headerInput.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
headerInput.close();
}
} else {//其餘檔案直接輸出到導出檔案
while ((len = is.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
is.close();
}
}
}
//------------------寫入新圖檔-start------------------
len = -1;
if (picList != null && !picList.isEmpty()) {
for (Map<String, String> pic : picList) {
ZipEntry next = new ZipEntry("word" + separator + "media" + separator + pic.get("name"));
zipout.putNextEntry(new ZipEntry(next.toString()));
InputStream in = new FileInputStream(pic.get("path"));
while ((len = in.read(buffer)) != -1) {
zipout.write(buffer, 0, len);
}
in.close();
}
}
//------------------寫入新圖檔-over------------------
//================================覆寫文檔-end================================
zipout.close();
zipFile.close();
} catch (Exception e) {
e.printStackTrace();
throw new Exception("生成word檔案失敗!");
}
}
public static void main(String[] args) {
//============================================createDocx(docx模闆測試)-begin============================================
String timeStr1 = DateUtils.getCurrentTime_yyyyMMddHHmmssSSS();
String templatePath1 = separator + "templates" + separator;
String outputFileTempPath1 = "temp" + separator + "doc" + separator + timeStr1 + separator;
String outputFileName1 = timeStr1 + "."+suffix_doc;
Map<String,Object> dataMap1 = new HashMap<String,Object>();
dataMap1.put("name", "王大壯");
dataMap1.put("age", "30");
dataMap1.put("phone", "18450098635");
dataMap1.put("mailbox", "12345678911");
System.out.println(createDoc(dataMap1, templatePath1, "docTemplates.xml", outputFileTempPath1,outputFileName1));
//============================================createDocx(docx模闆測試)-end============================================
//============================================createDocx(docx模闆測試)-begin============================================
URL basePath = WordUtils.class.getClassLoader().getResource("");
String picPath = basePath.getPath() + separator + "templates" + separator;
;
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("ymdhis", DateUtils.getCurrentTime_yyyyMMddHHmmss());
List<String> listTile = new ArrayList<>();
listTile.add("這是第一個标題");
listTile.add("這是第二個标題");
listTile.add("這是第三個标題");
dataMap.put("listTitle", listTile);
List<String> picTypes = new ArrayList<>();
picTypes.add("jpg");
dataMap.put("picTypes", picTypes);
List<Map<String, String>> picList = new ArrayList<>();
Map<String, String> picMap = new HashMap<>();
// 要按順序
picMap.put("path", picPath + "pic1.jpg");
picMap.put("name", "pic1.jpg");
picList.add(picMap);
picMap = new HashMap<>();
picMap.put("path", picPath + "pic2.jpg");
picMap.put("name", "pic2.jpg");
picList.add(picMap);
picMap = new HashMap<>();
picMap.put("path", picPath + "pic3.jpg");
picMap.put("name", "pic3.jpg");
picList.add(picMap);
dataMap.put("picList", picList);
List<Map<String, Object>> listTaleData = new ArrayList<>();
Map<String, Object> map = new HashMap<>();
map.put("name", "小明");
map.put("age", "11");
map.put("sex", "男");
map.put("grade", "五年級");
listTaleData.add(map);
map = new HashMap<>();
map.put("name", "小紅");
map.put("age", "12");
map.put("sex", "女");
map.put("grade", "六年級");
listTaleData.add(map);
map = new HashMap<>();
map.put("name", "小花");
map.put("age", "13");
map.put("sex", "女");
map.put("grade", "七年級");
listTaleData.add(map);
dataMap.put("listTaleData", listTaleData);
dataMap.put("summary", "總結總結總結!");
String timeStr = DateUtils.getCurrentTime_yyyyMMddHHmmssSSS();
String docxTemplateFile = "docxTemplates.docx";
String xmlDocument = "document.xml";
String xmlDocumentXmlRels = "document.xml.rels";
String xmlContentTypes = "[Content_Types].xml";
String xmlHeader = "header1.xml";//可以用來修改頁眉的一些資訊
String templatePath = separator + "templates" + separator;
String outputFileTempPath = "temp" + separator + "docx" + separator + timeStr + separator;
String outputFileName = timeStr + "."+suffix_docx;
String outputFileName2 = timeStr + "."+suffix_doc;
try {
createDocx(dataMap, docxTemplateFile, xmlDocument, xmlDocumentXmlRels, xmlContentTypes,
xmlHeader, templatePath, outputFileTempPath, outputFileName);
createDocx(dataMap, docxTemplateFile, xmlDocument, xmlDocumentXmlRels, xmlContentTypes,
xmlHeader, templatePath, outputFileTempPath, outputFileName2);
} catch (Exception e) {
e.printStackTrace();
}
//============================================createDocx(docx模闆測試)-end============================================
}
}
模闆所在目錄
模闆檔案
連結: https://pan.baidu.com/s/1ocavyv7wfB_bvptO3Ten3w 提取碼: fgwp
參考:https://blog.csdn.net/fenfenguai/article/details/78731331