news 2026/10/23 22:11:11

MuPDF mutool run 的 DOM 类型详解:以 Story 文档树编程方式构建 HTML 内容

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MuPDF mutool run 的 DOM 类型详解:以 Story 文档树编程方式构建 HTML 内容
  • 图形学
  • 图像处理

【免费下载链接】mupdf

mupdf mirror

项目地址:https://gitcode.com/gh_mirrors/mu/mupdf
点击查看免费下载

在 MuPDF 的 Story(HTML 排版布局)体系中,DOM类型是 JavaScript 绑定(mutool runshell)下访问和操作 Story 内部 HTML/DOM 文档树的唯一入口:它不提供构造函数,而是由Story.prototype.document()返回,随后即可通过 20 余个实例方法完成查找节点、克隆模板、增删子节点、读写属性等操作,从而在不修改原始 HTML 字符串的前提下动态生成最终排版内容。读完后,你将掌握:DOM的获取方式与生命周期约束、全部实例方法的参数语义与底层fz_dom_*C API 实现细节,以及如何用"模板 +clone()+remove()"的经典套路编写可运行的mutool run排版脚本(参考示例 docs/examples/storytest.js)。

1. DOM 在 Story 工作流中的位置

官方文档 docs/reference/javascript/types/DOM.rst 对DOM的定义是:

This represents an HTML or an DOM node. It is a helper class intended to access the DOM (Document Object Model) content of aStoryobject.

三个要点:

  1. 无构造函数。文档中.. class:: DOM后标注了|no_new|,即不能new DOM();实例只能通过 Story.prototype.document() 获得。

  2. |only_mutool|标记。整个DOM类只标注了|only_mutool|(类头部及getText()、getTag()上重复标注),意味着该类型只在mutool run脚本环境中可用。运行方式即:

    mutool run script.js [ arguments ... ]

    详见 docs/tools/mutool-run.md。

  3. 底层即fz_xml树。从源码结构看,JS 层每个DOM实例对应 C 层的fz_xml *节点。在 source/tools/murun.c 中,DOM.*方法被注册到名为fz_xml的 Userdata 类型下;每个ffi_DOM_*绑定函数(source/tools/murun.c)都只是取出fz_xml *后转调 source/fitz/xml-dom.c 中声明于 include/mupdf/fitz/xml.h 的fz_dom_*函数,并用fz_try/fz_catch捕获 C 层异常后以rethrow(J)转成 JS 异常。因此 C API 的注释可以直接作为 JS 方法语义的权威说明。

同一套 DOM 接口在其他语言绑定中也有对应物,例如 Java 平台类 platform/java/src/com/artifex/mupdf/fitz/DOM.java 及其 JNI 实现 platform/java/jni/dom.c,方法名与 JS 版基本一一对应,可用于交叉参考。

关键生命周期约束:DOM 只在首次排版前有效

这是使用DOM前必须理解的底层约束。C 头文件 include/mupdf/fitz/story.h 对fz_story_document的注释写明:

  • fz_story_document返回的是borrowed reference(借用引用),由 Story 持有并负责销毁,"Do not destroy this reference";
  • "This only makes sensebefore the first placementof the story or retrieval of the warnings. Once either of those things happen the DOM representation is destroyed."

也就是说:调用story.place(rect)或获取排版警告之后,DOM 树即被销毁,此前从story.document()取到的任何DOM引用都不再有效。所以正确的脚本结构是"先完成全部 DOM 操作,再进入place()/draw()循环",docs/examples/storytest.js 的示例正是按这个顺序编写的。

2. 实例方法完整参考

以下覆盖 DOM.rst 中列出的全部 22 个方法。除特别说明外,方法中的this就是被操作的节点。

2.1 获取文档骨架

方法签名返回值说明
body()dom.body()DOM返回body元素的 DOM
documentElement()dom.documentElement()DOM返回顶层(最外层)元素
createElement(tag)dom.createElement("div")DOM创建元素,尚未挂入 DOM
createTextNode(text)dom.createTextNode("text")DOM创建文本节点,尚未挂入 DOM

body()在 C 层的实现非常直接,就是对整棵树做一次深度优先查找(source/fitz/xml-dom.c):

fz_xml *fz_dom_body(fz_context *ctx, fz_xml *dom) { if (dom == NULL) return NULL; return fz_xml_find_dfs(dom, "body", NULL, NULL); }

documentElement()则是沿up指针走到文档根,再返回其首个子节点(source/fitz/xml-dom.c):

while (dom->up) dom = dom->up; return dom->down;

两个工厂方法createElement/createTextNode分别转调fz_new_dom_node与fz_new_dom_text_node(source/fitz/xml-dom.c):节点内存从文档自己的内存池(dom->u.doc.pool)分配,down/next/prev均为空——即"游离节点"状态。注意两点:

  • 元素节点会自动跳过命名空间前缀:fz_new_dom_node中若 tag 含:,只取冒号之后的部分作为标签名;
  • 文本节点的内容被逐字节拷入节点自身的池内存(u.node.u.text),down被置为MAGIC_TEXT标记,表示这是叶子文本节点。

由于这些节点尚未链接,创建后必须用第 2.3 节的插入方法挂到树上,否则不会出现在排版结果中。

2.2 查找与遍历

方法签名返回值说明
find(tag, attribute, value)dom.find("tag", "att", "val")DOM|null从当前节点起深度优先查找第一个匹配元素
findNext(tag, attribute, value)dom.findNext(...)DOM|null查找"下一个"匹配元素
firstChild()dom.firstChild()DOM|null第一个子节点,无则null
parent()dom.parent()DOM|null父节点,无则null
next()dom.next()DOM|null下一个兄弟元素
previous()dom.previous()DOM|null上一个兄弟元素

find/findNext的三个参数均可传null表示"任意",但语义并非简单的"通配"。C 层声明 include/mupdf/fitz/xml.h 给出精确规则:

  • tag == NULL:任意标签名都匹配;
  • att == NULL:不检查属性,所有标签都匹配;
  • att != NULL且match == NULL:只有带有该属性的节点才匹配(不比较值);
  • att != NULL且match != NULL:节点必须拥有该属性且值等于match。

实现上find转调fz_xml_find_dfs、findNext转调fz_xml_find_next_dfs(source/fitz/xml-dom.c),即深度优先遍历:先深入子树,再回溯找后续兄弟。这也解释了 storytest.js 中按id查找模板占位符的写法——id值唯一,深度优先找到的第一个命中就是目标:

var templat = body.find(null, "id", "filmtemplate"); ... var tmp = film.find(null, "id", "filmtitle");

firstChild有一个从源码可确认的细节:文本节点没有子节点,fz_dom_first_child遇到文本节点直接返回NULL(source/fitz/xml-dom.c);parent对文档根的首个元素(up指向 document 指针而非普通节点)同样返回null。

2.3 插入、移除与克隆

方法签名(this= 父节点/参照节点)说明
appendChild(childDom)parent.appendChild(child)把child作为最后一个子节点挂到this下
insertBefore(elementDom)ref.insertBefore(elt)在参照节点this之前插入elt
insertAfter(elementDom)ref.insertAfter(elt)在参照节点this之后插入elt
remove()elt.remove()把节点从 DOM 上摘下,不释放,可再挂到别处
clone()elt.clone()深克隆节点(含全部子孙与属性),克隆体未挂入 DOM

这三个插入方法的行为规则在 include/mupdf/fitz/xml.h 注释中明确:"unlinking the child from its current position if required",即插入即移动:如果节点已经在树中,会先从原位置摘出再插入新位置,无需手动先remove()。

以fz_dom_append_child(source/fitz/xml-dom.c)为例,实现分三步:

  1. 同文档校验:check_same_doc确认父子节点属于同一棵文档树,否则抛出FZ_ERROR_ARGUMENT:"Parent and child must be from the same document";
  2. 环检测:沿parent向上回溯,若遇到child本身则抛出 "Can't add a parent to its child."——不允许把父节点挂到自己的后代下;insertBefore/insertAfter也有对应检查("Can't add a node before/after its child.",source/fitz/xml-dom.c);
  3. 先摘再插:先把child的双向兄弟链(prev/next)断开,再找到parent->down链的末端把child接上,最后child->up = parent。

remove()只摘链不释放(source/fitz/xml-dom.c):断开prev/next后把up指回文档根指针,节点继续由文档内存池持有,之后可以随时appendChild到别处——这正是官方文档所说的 "The element can be added back elsewhere if required"。

clone()调用深拷贝函数clone_xml(source/fitz/xml-dom.c):文本节点直接复制内容;元素节点则逐条复制属性名/属性值串,再递归复制整条子节点链。克隆体与原文档共用同一个内存池,但完全未链接,需要显式插入。

2.4 属性与内容读取

方法签名返回值说明
addAttribute(attribute, value)elt.addAttribute("id", "x")DOM添加/设置属性,返回更新后的元素
removeAttribute(attribute)elt.removeAttribute("id")DOM删除属性,返回更新后的元素
getAttribute(attribute)运行时绑定名attributestring|null读单个属性值,无则null
getAttributes()elt.getAttributes()Object返回{属性名: 属性值}字典对象
getText()elt.getText()string|null节点文本内容(|only_mutool|)
getTag()elt.getTag()string|null节点标签名(|only_mutool|)

一个值得注意的命名细节:文档按getAttribute命名,但 source/tools/murun.c 的注册名是"DOM.attribute",文档自身示例也是xml.attribute("attribute")——在mutool run中实际调用名是attribute。

getAttributes()的实现体现了 C 层"按序号枚举属性"的接口形态(include/mupdf/fitz/xml.h):fz_dom_get_attribute(ctx, elt, i, &att)以i = 0, 1, 2, ...逐个取,取完返回NULL。JS 绑定侧就是一个 while 循环,把每次取到的att作为属性名、返回值作为属性值塞进新建的 JS 对象(source/tools/murun.c)。

3. 实战:三种内容构建方式(源自官方示例)

docs/examples/storytest.js 展示了用DOM构建 Story 内容的三种模式,均以"页面循环放置 + 绘制"收尾。先给出共用的排版循环骨架(第 2、3 种模式共用,docs/examples/storytest.js):

var mediabox = [0, 0, 512, 640]; var margin = 10; var placed; do { var where = [ mediabox[0] + margin, mediabox[1] + margin, mediabox[2] - margin, mediabox[3] - margin ]; var dev = writer.beginPage(mediabox); placed = story.place(where); story.draw(dev, Matrix.identity); writer.endPage(); } while (placed.more); writer.close();

3.1 纯编程式构建:document()+body()+ 插入

不写任何 HTML 字符串,直接拿 Story 的内部空文档手工搭树(docs/examples/storytest.js):

var writer = new DocumentWriter("out2.pdf", "PDF", ""); var story = new Story(null, "", 11); // 注意:buffer 传 null,由 DOM 构建内容 var dom = story.document(); // 取得 DOM(无构造函数) var body = dom.body(); body.appendChild(dom.createTextNode("This is some text.")); var tmp = dom.createElement("b"); // 创建元素(尚未挂入) body.appendChild(tmp); // 挂入 body tmp.appendChild(dom.createTextNode("This is some bold text.")); body.appendChild(dom.createTextNode("This is some normal text."));

要点:createElement与appendChild配合,"创建 → 挂接"是固定两步;所有 DOM 操作必须在第一个story.place(...)之前完成(见第 1 节生命周期约束)。

3.2 模板 + 克隆:find/clone/remove组合套路

这是DOMAPI 最有实战价值的用法:HTML 模板中放一个带id的占位节点,运行时find出来、循环clone填充数据、最后remove模板本体(docs/examples/storytest.js)。

模板(含占位li#filmtemplate,其内有#filmtitle、#director、#filmyear、#cast四个空占位符):

var festival_template = "<html><head><title>Why do we have a title? Why not?</title></head>" +"<body><h1 style=\"text-align:center\">Hook Norton Film Festival</h1>" +"<ol>" +"<li id=\"filmtemplate\">" +"<b id=\"filmtitle\"></b>" +"<dl>" +"<dt>Director<dd id=\"director\">" +"<dt>Release Year<dd id=\"filmyear\">" +"<dt>Cast<dd id=\"cast\">" +"</dl>" +"</li>" +"<ul>" +"</body></html>";

填充与清理(数据为films数组,字段title/director/year/cast):

var dom = story.document(); var body = dom.body(); var templat = body.find(null, "id", "filmtemplate"); // 按 id 定位模板 for (var f = 0; f < films.length; f++) { var film = templat.clone(); // 深克隆,未挂入 DOM var tmp = film.find(null, "id", "filmtitle"); // 在克隆体内部再查找 tmp.appendChild(dom.createTextNode(films[f].title)); tmp = film.find(null, "id", "director"); tmp.appendChild(dom.createTextNode(films[f].director)); tmp = film.find(null, "id", "filmyear"); tmp.appendChild(dom.createTextNode(films[f].year)); tmp = film.find(null, "id", "cast"); for (var j = 0; j < films[f].cast.length; j++) { tmp.appendChild(dom.createTextNode(films[f].cast[j])); tmp.appendChild(dom.createElement("br")); } templat.parent().appendChild(film); // 挂到模板同级 } templat.remove(); // 移除模板本体

这个套路里有几个值得点名的行为,全部可由源码印证:

  • 在克隆体上find是安全的:clone()返回的树与原树结构相同,find的深度优先搜索在克隆子树内独立进行;
  • templat.parent().appendChild(film)利用了"插入即移动/追加到末尾"的语义,克隆体每次追加为最后一个兄弟;
  • remove()不产生悬空风险:被摘下的模板节点仍由文档池持有,只是不再参与排版;
  • 若想在插入时精确定位而非追加到末尾,可改用insertBefore/insertAfter配合兄弟查找。

3.3 只读访问场景

getAttribute/getAttributes/getText/getTag/ 遍历方法适合在生成前检查或改写已解析 HTML。例如枚举某节点全部属性:

var p = body.find(null, "id", "a"); var dict = p.getAttributes(); // => { id: "a" } console.log(p.getTag(), p.getAttribute("id"));

对应的 C 侧函数是fz_dom_attribute(单属性,include/mupdf/fitz/xml.h,注释:返回借用指针或 NULL)与fz_dom_get_attribute(序号枚举)。

4. 底层实现速览(源码级补充)

把 JS 方法映射到源码,整体调用链为:

JS: dom.find(...) -> ffi_DOM_find (source/tools/murun.c) -> fz_dom_find (source/fitz/xml-dom.c) -> fz_xml_find_dfs (source/fitz/xml.c 的 XML 通用遍历)

从源码结构看,几个对使用者重要的设计事实:

  1. 内存全部归文档池。fz_new_dom_node/fz_new_dom_text_node(source/fitz/xml-dom.c)都在dom->u.doc.pool上分配,JS 层无需(也无法)单独释放节点;这也是remove()只做解链的原因——头文件注释明确 "No reference counting changes for the element"(include/mupdf/fitz/xml.h)。
  2. 节点即双向链表。每个节点持有down(首子)、up(父)、u.node.next/prev(兄弟双向链);insert*/remove本质都是对这三组指针的摘挂操作。
  3. 错误以异常形式上抛。同文档校验失败、成环操作等直接fz_throw(ctx, FZ_ERROR_ARGUMENT, ...),经ffi_*的fz_catch/rethrow(J)变成 JS 运行时异常,可在脚本中try/catch。
  4. document 指针是特殊根。fz_new_dom创建的 document item 不直接返回,而是返回其down(source/fitz/xml-dom.c);各 DOM 操作内部用skip_doc_pointer/doc_pointer处理这一层间接,用户代码无需关心。

C 语言侧可直接调用同一批fz_dom_*函数,C 版完整示例(fz_story_document+ DOM 操作,位置见 docs/examples/storytest.c 与 docs/examples/storytest.c)可与 JS 示例对照阅读。

5. 使用限制与常见陷阱

  1. 时机:DOM引用仅在首次story.place(...)之前有效(include/mupdf/fitz/story.h)。先排版后操作 DOM 属于未定义行为(底层树已销毁)。
  2. 环境:DOM是mutool run专属(|only_mutool|),不要在其他 JS 宿主中假定其存在;getText()、getTag()同样标注了该限制。
  3. 游离节点:createElement/createTextNode/clone的返回体都不在树中,忘记appendChild是"内容凭空消失"的最常见原因。
  4. 单属性读取的运行时名是attribute而非getAttribute(绑定注册见 source/tools/murun.c)。
  5. 跨文档移动会抛错:把 A 文档的节点插到 B 文档的树上会触发 "Parent and child must be from the same document";把节点插到自己的祖先上会触发成环错误。
  6. 文档原稿中的小笔误:DOM.rst中createTextNode的示例误写为xml.createElement("Hello world!"),实际应调用createTextNode,本文已按正确用法给出。

6. 参考文件清单

内容路径
DOM 类型官方参考(本文主体)docs/reference/javascript/types/DOM.rst
Story 类型参考(document()出处)docs/reference/javascript/types/Story.rst
mutool run使用说明docs/tools/mutool-run.md
JS 实战示例(本文示例来源)docs/examples/storytest.js
C 语言对照示例docs/examples/storytest.c
fz_dom_*C API 声明include/mupdf/fitz/xml.h
DOM 操作实现source/fitz/xml-dom.c
fz_story_document与 DOM 生命周期注释include/mupdf/fitz/story.h
JS 绑定函数与注册source/tools/murun.c、source/tools/murun.c
Java 平台同构绑定platform/java/src/com/artifex/mupdf/fitz/DOM.java
  • 图形学
  • 图像处理

【免费下载链接】mupdf

mupdf mirror

项目地址:https://gitcode.com/gh_mirrors/mu/mupdf
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 6:18:29

CMake之后libigl库文件夹结构详解:源码、构建产物与目标机制

简介&#xff1a;CMake构建后的libigl库压缩包&#xff0c;面向图形学、几何处理与CAD方向的C开发者与研究者。libigl为开源几何处理库&#xff0c;内置线性代数、几何操作、网格可视化、数值求解及离散微分几何等模块&#xff0c;支持法线计算、顶点变形、参数化等常见任务&am…

作者头像 李华
网站建设 2026/10/22 7:38:03

CubeSandbox:企业安全执行面的可验证沙箱边界

1. 项目概述&#xff1a;为什么企业级安全执行面需要 CubeSandbox 这个“数字保险箱”最近在给几家做工业自动化和金融后台系统的客户做安全加固方案时&#xff0c;反复被问到一个问题&#xff1a;“我们已经部署了 OpenClaw 和 DSH&#xff0c;为什么还要额外加一层 CubeSandb…

作者头像 李华
网站建设 2026/10/22 18:43:28

基于CART的随机森林分类器:红酒质量预测实战与调优

1. 项目缘起与整体设计思路红酒质量预测这个题目&#xff0c;在机器学习入门圈子里算是老面孔了。我最早接触它是在一次内部技术分享会上&#xff0c;当时有人拿它做分类算法的对比实验&#xff0c;后来我自己做项目复盘时又反复用过几次。这个数据集的好处在于&#xff1a;特征…

作者头像 李华
网站建设 2026/10/23 6:24:04

Linux服务器预约系统开发实战:Flask与SQLite应用

1. 项目背景与核心需求这个Linux服务器预约系统小项目是面向运维初学者的实践教程第三部分。在前两节课中&#xff0c;我们已经完成了基础环境搭建和用户管理模块&#xff0c;现在需要实现核心的预约功能模块。典型的应用场景包括&#xff1a;高校实验室的服务器资源分配中小企…

作者头像 李华