首页
归档
友情链接
关于
Search
1
在wsl2中安装archlinux
270 阅读
2
从零开始配置 vim(15)——状态栏配置
159 阅读
3
nvim番外之将配置的插件管理器更新为lazy
145 阅读
4
2018总结与2019规划
138 阅读
5
PDF标准详解(五)——图形状态
116 阅读
软件与环境配置
读书笔记
编程
Thinking
FIRE
菜谱
翻译
登录
Search
标签搜索
c++
c
学习笔记
windows
文本操作术
编辑器
NeoVim
Vim
win32
emacs
读书笔记
elisp
VimScript
linux
文本编辑器
投资理财
Java
反汇编
OLEDB
数据库编程
Masimaro
累计撰写
395
篇文章
累计收到
32
条评论
首页
栏目
软件与环境配置
读书笔记
编程
Thinking
FIRE
菜谱
翻译
页面
归档
友情链接
关于
搜索到
395
篇与
的结果
2026-08-07
《elisp 每日一练》——文本搜索与正则表达式匹配
前面我们介绍的都是与光标位置有关的东西,都是建立在光标基础上的操作。很多时候我们在完成相关功能时都要借助光标的移动来完成。相信各位读者看到现在已经可以很熟练的通过光标来完成一些简单的工作了。现在我们正式进入文本操作的范畴,文本也是一种数据,数据的操作无外乎增删改查。我们先从查开始基础知识铺垫Emacs中查找主要通过正则表达式。关于正则表达式的详细内容我就不介绍了,如果你写过代码应该对它的概念不陌生。而Eamcs的正则表达式虽然与主流的编程语言在规则写法上略有不同,不过现在有AI的加持,只要说出需求一般AI可以给出准确的正则表达式Emacs 中常用的搜索函数是 search-forward 和 re-search-forward。它们一个是严格的字符串匹配一个是正则表达式搜索。(search-forward STRING &optional BOUND NOERROR COUNT) (re-search-forward REGEX &optional BOUND NOERROR COUNT)它们的参数都是类似的:STRING/REGEX 是待匹配的子串或者正则表达BOUND 表示搜索的截至位置,如果传入nil表示搜索结束位置为buffer的结尾或者指定的 Narrow 结尾NOERROR: 是否进行错误处理,如果为nil的话会进行错误处理,默认的情况下会直接报错崩溃,一般建议是这里设为 tCOUNT : 表示要跳转到后面第几次匹配的位置, 如果为负数的话会向前查找。对于这两个函数,我们需要注意下面几点:光标移动的“副作用”,它会将光标跳到匹配文本的末尾BOUND 表示的是搜索结尾的位置,那么它的开始位置是在哪呢?它的开始位置是光标当前所在区域。或者是当前Narrow区域的开始位置。COUNT 为负值时光标会停留在匹配文本的开头,类似于调用 search-backward 和 re-earch-backwardsearch-backward 和 re-earch-backward 函数中如果参数为正值,会向上找并将光标放置于匹配文本的开头。为负值的行为与 search-forward 和 re-search-forward 相同匹配到的文字我们可以通过 match-string 来获取。它的原形如下:(match-string NUM &optional STRING)理论上讲,采用精确匹配的情况下,我们已经知道了匹配得到的字符串是什么了,不需要额外的获取,但是Emacs还是在 match-string 函数中给这种情况提供了支持。NUM如果我们使用精确匹配的方式一般填写0就好了,因为0代表获取完整匹配的字符串,而填入1、2、3这样的数字用来获取正则表达式中第 N 个捕获组(括号 ( ... ) 内匹配的内容)。STRING 是一个可选的参数,如果我们上一次匹配是从buffer中进行的,那么这次我们可以在这个位置给 nil 或者默认,如果是在一个字符串中执行了匹配,那么我们需要填写上次匹配的字符串。这个函数还需要注意:该函数不执行匹配操作,永远只获取上一次执行匹配的结果。所以我们在执行匹配之后保存结果之前不能再次执行匹配否则之前的结果会丢失。执行完匹配之后除了可以自己写代码进行保存之外,Emacs还提供了宏 save-match-data 用来保存之前执行匹配的结果。既然我们上面说,match-string 可以获取上次从字符串中执行匹配操作的结果,那么怎么利用字符串执行匹配操作呢?Emacs提供了函数 string-match。(string-match REGEXP STRING &optional START)它的函数原形就简单很多了。这里就不细说了。需要注意,Emacs并没有为精确匹配提供类似的函数。实际上通过正则表达式也可以执行精确匹配。如果你不想获取匹配结果只想知道是否能有匹配结果可以通过函数 string-match-p,与 match-string 相比它少了将匹配结果保存的操作,效率更高一些。匹配相关的函数我们都介绍的差不多了,接着再介绍一下匹配时与光标有关的函数。match-beginning 可以获取匹配项起始位置,它接收一个整数参数,参数的含义与 match-string 中的相同match-end 可以获取匹配项结尾位置的Point,它接收一个整数参数,参数的含义与 match-string 中的相同练习有了这些函数,我们来写一个能循环匹配的查找函数。所谓的循环匹配就是匹配到最后一个之后能跳转回来从开头位置继续匹配。并且我们希望匹配时将光标置于匹配字符串的开头位置。这里为了简单,我使用search-forward 精确匹配作为演示(defun my/search-forward-loop(regexp) (interactive "sSearch regexp (loop):") (save-restriction (widen) ;;全局查找,需要取消之前设置的narrowing 限制 (let ((start-point (point))) ;; 这里需要保留光标移动,只有在未找到时才恢复 (when (looking-at regexp) ;; 如果光标已经在匹配位置的开头,需要移动到下一个,防止一直匹配同一个位置的尴尬情况 (forward-char 1)) (if (search-forward regexp nil t) (goto-char (match-beginning 0)) ;; 未找到,尝试跳转开头,重新查找 (progn (goto-char (point-min)) (if (search-forward regexp nil t) (goto-char (match-beginning 0)) (goto-char start-point)))))))总结本篇不涉及什么Emacs的处理机制,就是简单的一些函数介绍,这些函数我们在其他编程语言中或多或少都见过或者用过。所以本篇实际就是一篇无聊的摘录相关函数的东西。本次介绍的函数如下:search-forward 进行向后的精确匹配search-backward 进行向前的精确匹配re-search-forward 进行正则表达式的向后匹配re-search-forward 进行向后的正则表达式匹配match-string 获取匹配结果string-mach 使用字符串进行匹配string-match-p 判断当前是否有匹配结果match-beginning 获取匹配结果的起始位置match-end 获取匹配结果的结束位置
2026年08月07日
1 阅读
0 评论
0 点赞
2026-08-06
《elisp 每日一练》——Narrowing与Widen
在前面我们介绍了Region的概念,它就是由 marker 和 point 构成的一个区域。Region 是一种极度临时的状态——我们可以通过移动光标随时修改它的区域,或者激活/取消激活。光标可以随意移动出Region所在区域,以后如果基于这个条件进行操作时容易造成误操作Region 可以说只有在激活那一刻才有效,后续如果改变了point 或者 marker 就会造成改变为了应对这些问题,Eamcs中又有了 narrowing和widen 机制。它能将 Buffer 的可视与操作范围强制限制在特定区域内,为你提供一个安全、持久的局部编辑“沙盒”。背景知识铺垫Emacs 中允许我们通过 Narrowing 机制将注意力只集中在 Buffer 的某一部分,而把其余所有的文本暂时“隐身”。这样即使某些操作全局的函数此时也只能操作该范围内的文本。首先,Narrowing 会改变 (point-max) 、(point-min) 的值,在默认没有 Narrwoing 的情况下, point-min 总是返回1,而 point-max 返回buffer中最后一个字符所在的索引。但一旦对 Buffer 施加了 Narrowing, point-min 、point-max 的值就与我们设置的可见区域的区间有关了。我们可以通过 (narrow-to-region START END) 将buffer的可见区域设置为从 START 到 END之间的区域。通过 (widen) 来解除所有之前设置的 narrowing。实际上于 mark 、point 类似,对于 Narrowing 我们也要考虑备份和恢复现场的情况,保存和恢复现场使用的函数是 save-restriction。在一些插件的代码中我们经常看到 save-restriction 和 save-excursion 成对出现。它们的用法也是类似的。既然它们一般是成对出现的,我们不妨考虑一下它们两个谁在外层,谁在内层呢?假如 save-restriction 在外层,而 save-excursion 在内层, 也就是这样;; 假设当前光标位于位置 (Point = 400) (save-restriction (save-excursion (widen) ;; 暂时恢复可见区间为整个buffer (narrow-to-region (point-min) 200) (goto-char (point-max)) ;; 执行其他操作 ))上述例子中,我们通过 narrow-to-region 将可见区域限制在 1 到 200 这个区间。然后执行一些操作。当要恢复时先调用 save-excursion 尝试恢复光标位置到最初的 400时发现它的可见区域最多到200,此时光标位置可能被置于200位置。最后执行 save-restriction 恢复可见区域,但是此时光标已经回不到最初的 400位置了。从上面可以看到最好是将 save-excursion 放在外层。练习本次的例子,我们实现一个简单的统计指定行中单词的个数。这次用到的函数为 count-words , 它的原形如下:(count-words START END &optional TOTALS)它主要接收两个参数:统计的起始位置和结束位置。获取行的区间坐标可以通过 forword-line 移动光标到指定行,然后通过 line-beginning-position 和 line-end-position 获取行首和行尾位置的 point值利用今天所学的内容可以写下如下的代码:(defun my/count-words-in-line(line) ;; 获取行首和行尾位置 (save-excursion (save-restriction (widen) ;; 先取消之前所设置的可见区间恢复整个buffer可见 (goto-char (point-min)) (forward-line (1- line)) ;; 移动到对应行 (narrow-to-region (line-beginning-position) (line-end-position)) (count-words (point-min) (point-max)) )))总结本次我们了解了 Emacs 中的 Narrowing与Widen 机制。后面我们写代码操作文本时可以先直接默认操作整个区域,而在需要关注操作区间时通过 narrow-to-region 设置可见区间。不过不要忘了通过 save-restriction 恢复之前设置的可见区间。本次我们介绍的api函数有:narrow-to-region 设置可见区间widen 取消之前设置的所有可见区间save-restriction 恢复调用之前的可见区间
2026年08月06日
1 阅读
0 评论
0 点赞
2026-08-05
《elisp 每日一练》——Mark Ring
在前面我们了解了 mark 的机制以及通过 mark 和 point 来组成region,这次我们将深入了解 Emacs 是如何 管理 mark 的。背景知识铺垫我们通过 push-mark 和 pop-mark 来管理mark。根据这两个名词我们可能会认为Emacs 是通过栈来管理的,push-mark 表示压栈而 pop-mark 表示出栈。其实Emacs管理mark的真实手段确实遵循后进先出的栈规则,但是pop-mark 并不会从栈结构中删除对应的mark记录。想象一下这个场景:我们之前记录了 A、B位置,现在又记录了C位置,如果我想回到上一次的B位置,要通过 pop-mark 让C位置出栈,但是如果后续我又想回到C位置怎么办呢?如果出栈的时候销毁了数据就回不来了。所以实际上 push-mark 会放入新的mark但是 pop-mark 只会改变指向栈顶的位置。并且可以在A、B、C之间循环也就是可以从C回到A。考虑到这些场景,我们能想到应该是一个环形的链表,或者是一个首尾相连的数组,构成一个环形的数据结构。这也就是 Mark Ring 这个名称的由来。实际上mark ring 并不能无限增长,它有一个大小的上限, 通过变量 mark-ring-max 指定,一般默认的是16。如果 ring 的长度超过了 mark-ring-max , 最旧的一个会被抛弃。我们还是以A、B、C三个位置来举例说明每次 push-mark 和 pop-mark 之后,Emacs 如何组织环。首先调用 (push-mark A) 来入栈A位置,此时栈的值为 '(A)结着调用 (push-mark B) 来入栈B位置,此时栈的值为 '(B A)然后调用 (push-mark C) 来入栈C位置,此时栈的值为 '(C B A)如果要回到C位置,直接调用 (goto-char (mark)) 此时栈不变如果要回到B位置, 先调用 (pop-mark) ,Emacs 会取出C的值,然后放到列表的尾部,此时整个栈会变成 '(B A C)如果要回到A位置,与上面类似,先出栈,Emacs会改变栈数据,变为 '(A C B)所以说我们如果想要回到上一步一个办法是不断调用 pop-mark 使对应位置。另一个办法就是回归 mark-ring 的本质,我们在 C/C++ 语言中可以通过数组的方式来实现,但是在Emacs Lisp中,它就是一个列表。我们可以通过操作列表方式来做到这点。实际上,Emacs 通过 mark-ring 这个变量记录着当前所维护的栈。我们可以通过 describe-variable 来查看变量,并结合push-mark 和 pop-mark 函数来观看每次改变之后具体的变量值来观察上述过程。另外,Emacs 实际维护了两组 mark-ring,一组是与buffer相关的,一组是全局的。这个很好理解,如果我们要在多个buffer中进行跳转,自然使用全局的 global-mark-ring 。但是我们并不需要特别的执行global-push-mark 这样的函数来操作全局,实际上也没有这个函数。当我们执行 push-mark 时Emacs会自己考虑是否要加入 global-mark-ring。也就说我们通过 push-mark 和 pop-mark 任意操作,Emacs会保证我们能正确记录。一个好的习惯是涉及到大范围的光标转移不要直接使用 goto-char 就完事了,而是要考虑后续是否需要回来,也就是提前调用 push-mark 记录一下。练习有了这些知识作为铺垫,我们今天来做一个相对复杂一点的练习。我们写一个函数,它主要功能如下:在minibuffer 中列举 mark-ring 中的值,以buffer名称、行号和 point 值的形式显示用户在选择某一项之后跳转到当前位置本次练习我们用到的函数和变量如下:mark-ring:记录当前所有保存的mark记录marker-position : 获取当前mark记录的point位置line-number-at-pos : 计算当前point 在buffer的第几行marker-buffer : 获取当前 mark 记录的buffer 对象buffer-name: 获取对应buffer 的名称前面几个函数比较简单,我们看名称和用法大概能知道怎么用,下面我们重点介绍一个与minibuffer相关的函数: completing-read它的函数原形如下:(completing-read PROMPT COLLECTION &optional PREDICATE REQUIRE-MATCH INITIAL-INPUT HIST DEF INHERIT-INPUT-METHOD)它用来弹出一个minibuffer的补全菜单,根据用户选择返回对应选项的字符串。PROMPT 是提示词,用于提示用户这个菜单是干什么用的。COLLECTION 是一个列表,它可以存储字符串或者以字符串为 key 的列表/哈希表/符号表,我们这里可以构建一个每项都是 '(提示词 . mark对象) 的关联CELL,后续根据用户选择所关联的 mark 进行跳转至此我的实现思路如下:遍历 mark-ring 取出其中的每个marker针对 marker 调用函数获取buffer名称、行号、位置等信息根据上述信息组织字符串并组成一个 '(字符串 . mark对象) 的关联CELL将关联CELL传入 completing-read,弹出提示根据用户选择提示,获取返回选中的字符串根据字符串查找关联的 mark 对象最后执行 goto-char 跳转到mark对象所对应的位置以下是我实现的代码:(defun my/browser-mark-ring () (interactive) (if (null mark-ring) (message "mark-ring 为空") (let ((candidates '())) (dolist (m mark-ring) ;; 遍历列表 (when (and (markerp m) (marker-position m) (marker-buffer m)) (let* ((position (marker-position m)) (buf (marker-buffer m)) (line (line-number-at-pos position buf)) (bname (buffer-name buf)) (label (format "%s | Line %d, pos %d" bname line position))) ;; 组织对应数据结构 (label . marker) (push (cons label m) candidates)))) (if (null candidates) (message "没有有效的 Mark 位置") (let* ((selected (completing-read "请选择要跳转的位置: " candidates nil t))) (when selected (let ((target (cdr (assoc selected candidates)))) (push-mark) (switch-to-buffer (marker-buffer target)) (goto-char target))))))))最终它的效果如下:总结本篇我们详细了解了 mark-ring 的机制,它就是一个列表,我们可以通过操作列表的方式操作它。并且Emacs自己为每个buffer维护了专属的 mark-ring 和通用的全局 global-mark-ring另外通过这个例子我们知道下面几个函数:mark-ring:记录当前所有保存的mark记录marker-position : 获取当前mark记录的point位置line-number-at-pos : 计算当前point 在buffer的第几行marker-buffer : 获取当前 mark 记录的buffer 对象buffer-name: 获取对应buffer 的名称completing-read : 负责与minibuffer 交互并返回选中的字符串值
2026年08月05日
1 阅读
0 评论
0 点赞
2026-08-04
《elisp 每日一练》—— Region
在关于保存和恢复现场的例子中,我们了解了 marker 相关的概念。 marker 与 point 都是一个表示位置的整数,不同的是marker 是动态的,由Emacs 内部根据buffer内容变化进行维护,而 point 是静态的。它们两个结合可以构成我们今天要讨论的内容——Region知识铺垫在图形界面编辑器中,我们经常用鼠标拖拽高亮一段文字,这叫“选中文本”。而在 Emacs 中,这个概念叫 Region(选区)。Region 是一个区间,它由 marker 和 point 组成的闭区间。我们可以使用这样一个公式来描述区间的组成: $\text{Region} = [\min(\text{point}, \text{mark}), \max(\text{point}, \text{mark})]$也就是说,起始位置是从 marker 和当前 point 中找到最小值,终点位置是二者的最大值。所以我们需要注意,在编写有关 Region 的代码时绝对不能假设使用 marker 或者 point 作为起点,或者终点。实际上为了解决这个问题,Emacs 提供了两个函数 region-beginning 和 region-end 来获取Region的开始和结束位置我们如何激活一个区域呢?可以通过 activate-mark 函数来激活当前选取。又或者可以通过 push-mark 在创建 marker 的同时进行激活。实际上 push-mark 的函数原形为:(push-mark &optional LOCATION NOMSG ACTIVATE)后面几个参数是可选的,它们分别代表:LOCATION: 表示作为新 marker 位置的 point 值,默认是当前光标所在位置NOMSG : 是否在minibuffer 中显示 Mark set 字样, 默认为 nil 也就是不显示ACTIVATE : 是否激活当前 Region,默认为 nil 不激活我们可以用一个例子测试一下(defun my/select-whole-buffer() (interactive) (goto-char (point-min)) (push-mark (point-max) nil t))上述例子我们将整个buffer的内容进行全选。另外,在操作 Region 时我们经常需要判断当前 Region 是否出于激活状态。因为前面介绍过实际上 marker 和 point 天然就构成一个 Region。如果不进行判断,用户想要进行某些操作时会出现不可预料的情况。例如如果用户想在选中区域进行查找,或者在当前行查找,如果不进行判断,有可能用户没有进行选区操作但是查找时却因为隐藏的region 导致查找发生在region中我们可以通过 use-region-p 来判断用户是否激活了选取练习我们实现一个函数,如果用户选择了一个区域,那么将区域中的所有字母大写,否则将光标所在单词的字母转换为大写。将单个单词大写可以使用函数 upcase-region,而将单词大写可以使用 upcase-word。upcase-region 接收一个起始位置和结束位置,将这个区间内的字母都转化为大写upcase-word 接收一个整数,表示将光标所在位置之后(正数)、或者之前(负数参数)的多少个单词转化为大写upcase-word 仅仅只能将光标所在位置到单词结尾位置的字母大写,我们还需要一个方法来获取单词首部,然后通过 goto-char 的方式移动光标到词首,最后执行 upcase-word。当然有了范围也可以使用 upcase-region获取光标所在单词的范围可以使用 bounds-of-thing-at-point。他的函数原形如下:(bounds-of-thing-at-point THING)它支持的 THING 非常多,有 word 、list、symbol、number、filename 等等。这里我们使用 word 就行。它返回的格式是 (START . END)。结合之前介绍的CELL的操作就可以获得START 的值。我们可以做一个快速选择光标所在单词的函数来看看它的用法(defun my/select-the-word() (interactive) (let ((bound (bounds-of-thing-at-point 'word))) (progn (goto-char (car bound)) (push-mark (cdr bound)) (activate-mark))))这里我们确实需要改变mark栈来和当前光标位置组成一个新的区域,所以不适合使用 save-excursion 来保存和还原现场。另外这里也演示了如何通过 activate-mark 来激活一个选区。有了这些函数的辅助,最终我的实现如下:(defun my/upcase-target() (interactive) (if (use-region-p) ;;用户选中区域,转换区域内文本 (upcase-region (region-beginning) (region-end)) ;;用户未选中文本,直接转换单词 (save-excursion ;; 考虑保存和恢复现场 (let ((bound (bounds-of-thing-at-point 'word))) (if bound (progn (goto-char (car bound)) (upcase-word 1)))))))至于如何使用 upcase-region 来实现将光标所在单词转化为大写的功能,读者可以自行尝试。总结本篇我们了解了 Region 的概念,以及可以使用Region做一些事情。后续很多高级的功能都可以依赖region 来实现,例如在选区中搜索、替换文本。今天介绍的函数有:region-beginning 和 region-end 获取选区的区间activate-mark 激活选区use-region-p 判断选区是否激活upcase-word 将光标所在位置之后的单词转化为大写upcase-region 将对应区间的单词转化为大写bounds-of-thing-at-point 获取指定事物所在的区间
2026年08月04日
1 阅读
0 评论
0 点赞
2026-08-03
《elisp 每日一练》——现场保护与还原
在第二天里面,我们介绍了光标移动和行索引区间的api函数,并且学会了如何移动光标。实际上在操作Emacs进行文本编译的动作时很多时候都在移动光标,只是我们感觉不到。如果我们随意的移动光标就容易带来一个问题,我们执行某个操作之后找不到光标在哪了,如果我们还要花时间找光标在哪,就很浪费效率背景知识铺垫如果我们自己实现一个简单的版本,我们可能会考虑使用 let ((old-point (point))) 这样的语句来先保存最后通过 (goto-char old-point) 来进行设置。这个思路是对的,但是它主要的问题是point记录的是当前位置的索引,如果我们在当前光标位置之前插入了字符串改变了光标所在文本的索引,那么根据之前保存的位置值就是一个错误的值。为了解决上一个问题,Emacs引入了 marker 的概念。与 point 不同,marker 是指向 Buffer 中特定位置的指针。如果在 Marker 之前插入或删除了文本,Emacs 的 C 语言底层会自动更新 Marker 的数值,使其依然指向原本对应的那个文字点。我们可以通过 push-mark 和 pop-mark 来保存和恢复一个mark。从名字上可以看出来它是一个栈操作。需要注意的是,出栈时并不会返回栈顶存储的值,我们可以通过 mark 函数来获取栈顶的值。为了清楚的体现第二个区别,我们可以写这样一个例子(defun my/test-point() (interactive) (let ((old-point (point))) (goto-char (point-min)) (insert "this is a test for point") (goto-char old-point))) (defun my/test-mark() (interactive) (push-mark) (goto-char (point-min)) (insert "this is a test for point") (goto-char (mark)) ;; 通过栈顶记录的marker 值来恢复光标位置 (pop-mark))到这里我们似乎解决问题了。我们可以通过管理marker 栈来实现保存和恢复现场。但是我们没有考虑一种情况:如果在push 之后 pop 恢复之前程序出错了,怎么办?我们要为这段代码加一个异常处理机制。到这里我们就可以请出今天的主角了—— save-excursion 。这是一个用C实现的底层函数,用于保证在任何情况下都能恢复现场。你可以将它理解为底层通过try catch 包装了的 push-mark 和 pop-mark。根据这点我们可以知道它需要注意的点:它只用来还原光标现场,而不会还原中间对文本执行的插入、删除操作避免在大的循环中使用,多次进行 push-mark和 pop-mark 会不断制造和销毁临时的marker资源,影响性能它的定义如下:(save-excursion &rest BODY)我们可以将上面的例子做一个改动(defun my/test-mark() (interactive) (save-excursion (goto-char (point-min)) (insert "this is a test for marker")))有了这个函数,我们能从繁琐的现场保存与恢复中解放出来,将注意力集中在具体功能的实现上。练习这次的练习比较简单,我们就简单的为上一次的复制行的函数加一个恢复现场的功能(defun my/duplicate-current-line() (interactive) (save-excursion (let ((line-next (buffer-substring (line-beginning-position) (line-end-position)))) (goto-char (line-end-position)) (newline) (insert line-next))))总结本篇我们讨论了Emacs中保护和还原现场,提到了 marker 和 point。同时也引入了 save-excursion 来保护和还原现场。既然本篇章提到了 marker,Emacs中 marker 和 point 组成了一个很重要的概念—— region。一般我们叫它选区,也就是在编辑器中我们看到的用户选中区域。这部分内容将在后面进行介绍。
2026年08月03日
3 阅读
0 评论
0 点赞
2026-08-02
《elisp每日一练》——行定位与光标移动
在第一天,我们知道buffer实际上就是一个存储文本信息的缓冲区,而光标所在位置就是缓冲区中当前字符的索引。并且这个缓冲区有一个最大索引和最小索引。除非我们要对整个缓冲区进行清空、全选、移动到开头或者结尾位置等操作,一般来讲我们用到全缓冲区的操作并不多,更多的是快速移动光标然后插入字符或者在行间进行跳转。所以今天我们主要介绍行操作和光标移动背景知识铺垫依靠光标所在位置可以定位当前行所在的索引区间,通过函数 line-beginning-position 和 line-end-position 来获取前行行首或行尾的 Point 位置。但是和它们第一天介绍的 point 等函数类似,只是获取对应的索引位置,并不会改变光标位置。如果要改变光标位置,通过 goto-char 来实现依靠这些知识,我们实现一个移动到下一行或者上一行的函数(defun my/goto-next-line() (interactive) (let* ((next-start (+ 1 (line-end-position))) (next-start (min next-start (point-max)))) (goto-char next-start)))上述实现的思路就是通过 line-end-position 定位到当前行尾的下一个位置。但是需要考虑是否是最后一行了。最后再通过 goto-char 移动光标到对应位置。如果我们通过循环或者递归的方式调用该函数就可以实现连续向下移动N行。但是这个功能Emacs也提供了,它的函数名为 forward-line。如果传入的参数N为正数则往下移动N行,为负数则表示向上移动N行。练习在vim中,我们常用的操作是 yy 拷贝当前行然后 p/P 粘贴到下一行或者上一行。今天的练习我们来尝试实现这么一个功能,为了简单我们就不支持拷贝多行了。我们可能用到的API函数有:line-beginning-position 和 line-end-position 用来定位范围buffer-substring 用来拷贝字符串goto-char 移动光标位置newline 插入新行insert 用来在光标所在位置插入字符有了这些铺垫,我们可以实现这个函数了(defun my/duplicate-current-line() (interactive) (let ((line-next (buffer-substring (line-beginning-position) (line-end-position))) ;拷贝当前行所在字符 ) (goto-char (line-end-position)) (newline) ;; 插入新行 (insert line-next)))总结本篇介绍了行操作有关的api函数。并且实现了一个简单的复制当前行到下一行的函数。但是这个例子不太完美,因为我们发现执行玩之后光标停留在下一行的行尾位置,如果我们不希望改变光标位置,让它停留在当期行该怎么办呢?下一篇我们将解决这个问题,来讨论如何恢复现场
2026年08月02日
6 阅读
0 评论
0 点赞
2026-08-01
《elisp每日一练》——point与buffer-substring
之前我根据自己学习Emacs的经验发布一系列的博客来介绍Emacs、ELisp,并且完成了一套相应的配置。但是说实话那些配置也是我在一些论坛之中抄过来的,有的带有些许的个人色彩的改动,只能满足最基本的要求。如果你要我对这些配置做更细的改动,或者要深入具体的插件,做更细化的改动,我目前是做不到的。基于以上水平,我在懒猫的博客 找到更进一步学习elisp 的方法。简单来说接下来要做的就是一页一页的翻看 Emacs Lisp Reference Manual, 一个 api 一个 api 的练习。现在就像初学C语言时只能写写hello world,打印斐波那契数列,或者99乘法表这样简单的程序。想要写更复杂的程序需要学习操作系统、网络原理、数据库、甚至跨专业的知识。我每天上班摸鱼时看看,目前持续了一个月左右,看的内容并不多但是有些收获,所以我打算开启一个新系列,来跟读者分享我的学习心得。我考虑到,如果我的这个新系列也按照 Emacs Lisp Reference Manual 来组织一来是太细也太繁琐了,二来里面的有些内容很多跟我一个水平的读者已经很熟悉了,再介绍一次有些重复了。所以我按照我个人的理解来组织后续的内容。废话不多数我们正式进入正题。本篇我们从最基础的内容开始介绍前提知识提要首先简单介绍一下 buffer 的概念。如果你写过从文本文件中读取文本的简单程序就会理解,如果我们要读取文本首先要做的就是开辟一块内存来存储读出来的内容。你可以将emacs 中的buffer做同样的类比,它就是一块在内存中存储文本的区域,但是通过窗口将这个区域呈现在我们眼前。用C/C++ 的概念来讲,它就是一个 char const*对于这个 char const* 数组来说,想要访问它主要有三个变量需要记录:当前的index、数组开始的索引和最大元素所在的索引,对应于Emacs中的 buffer ,它提供了三个函数 point 、point-min、point-max 分别获取当前光标所在索引,最大索引位置和最小索引位置。我们通过 (buffer-substring START END) 来提取当前buffer中从 start 到 end 之间的字符串练习下面我们通过一个简单的例子来实践、了解这些函数的作用。我们实现一个 my/get-word-at-point 的函数,它用来取光标位置前后各5个字符的字符串并通过message 函数来显示。我们实现的思路如下:通过 (- point 5) 和 (+ point 5) 来获取光标前后各5个字符的位置。通过 buffer-substring 来提取数据使用message 打印结果(defun my/get-word-at-point () (interactive) (let* ((curr (point)) (start (max (point-min) (- curr 5))) (end (min (point-max) (+ curr 5))) (substring (buffer-substring start end))) (message "%s" substring)))总结本次我们简单介绍了 buffer 的基础概念,同时重点介绍了三个获取位置相关的函数 point 、point-min、point-max。以及用来做练习的 buffer-substring 函数。下一篇我打算介绍行操作有关的内容,行开始的位置和结束的位置可以通过 line-beginning-position、line-end-position。读者可以提前考虑一下怎么通过这两个函数完成复制当前行的内容
2026年08月01日
4 阅读
0 评论
0 点赞
2026-07-25
PDF 标准详解(八)——混合模式
所谓的混合模式,也就是颜色的混合。混合模式是从PDF1.4标准开始引进。它决定了上层图形和下层已有内容重叠时该如何显示它们的混合色。混合模式的写法混合模式是通过扩展图形状态字典来定义的,刚刚好我们在上一篇文章中介绍了这个概念。它的键名为 /BM 后面跟模式的名称。PDF支持的混合模式类型如下:模式名称中文计算方式Normal正常模式默认模式,上层直接覆盖下层(结合透明度时按比例混合)Multiply正片叠底$C_r = C_s \times C_b$。颜色相乘,结果总是变暗,类似两张半透明彩色胶片重叠Screen滤色互补色相乘,结果总是变亮,类似多盏彩色灯光重叠照射Overlay叠加根据背景暗/亮自动切换 Multiply 或 Screen,保留背景的高光与阴影Darken变暗取两者各通道的最小值SoftLight柔光其核心作用是根据源颜色的明暗来使背景色变暗或变亮,效果类似于在背景上照射一束漫射的柔和聚光灯HardLight强光当顶层源颜色较亮时,效果类似滤色;当较暗时,类似正片叠底,视觉效果如同用强烈的光束照射背景Difference差值取两者 RGB 值的绝对差,常用于色彩反转和对比校验Exclusion排除其效果类似于差值模式但反差更柔和, 计算公式为 $C_r = C_b + C_s - 2 \times C_b \times C_s$示例下面我们来看具体的例子:2 0 obj << /Type /Page % 这是一个页面 /MediaBox [0 0 700 792] % 纸张尺寸为美国信肖像(612点x792点) /Contents [3 0 R] % 图形内容在对象4中 /Resources << /Font << /F1 5 0 R % 注册字体信息 >> /ExtGState << /GS_Normal <</BM /Normal>> /GS_Multiply <</BM /Multiply>> /GS_Screen <</BM /Screen>> /GS_Overlay <</BM /Overlay>> /GS_Darken <</BM /Darken>> /GS_SoftLight <</BM /SoftLight>> /GS_Difference <</BM /Difference>> /GS_Exclusion <</BM /Exclusion>> >> >> endobj 3 0 obj % 页面内容流 << >> stream % 流的开始 0.8 0 0 0 k 100 150 400 400 re f %底色采用青色的矩形 %======= Normal 模式============== /GS_Normal gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 0 500 Td (Normal) Tj ET %====== Multiply 模式 ================== /GS_Multiply gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 0 400 Td (Multiply) Tj ET %====== Screen 模式 ================== /GS_Screen gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 0 300 Td (Screen) Tj ET %====== Overlay 模式 ================== /GS_Overlay gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 0 200 Td (Overlay) Tj ET %======= Darken 模式============== /GS_Darkens gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 350 500 Td (Darken) Tj ET %====== SoftLight 模式 ================== /GS_SoftLight gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 350 400 Td (SoftLight) Tj ET %====== Difference 模式 ================== /GS_Difference gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 350 300 Td (Difference) Tj ET %====== Exclusion 模式 ================== /GS_Exclusion gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 350 200 Td (Exclusion) Tj ET endstream % 流结束 endobj 5 0 obj << /Type /Font /Subtype /Type1 % 标准 Type1 字体字典定义 /BaseFont /Helvetica %内置 Helvetica 字体,无需嵌入字体文件 >> endobj上面的例子我们往Page对象中注册了资源,里面有:字体资源、混合模式资源。字体资源我们引用了5这个对象,而混合模式的资源我们注册在 ExtGState 里面,它的注册与上一篇中注册图形状态字典类似。我们注册了8种混合模式的字典。接着我们在页面中绘制了一个大的青色矩形作为底色,然后在适当的位置绘制了各种字符,通过字符颜色和底色进行混合来测试不同混合模式的显示效果,最终的效果如下:关于字体相关的部分,我们将在后面进行介绍。这里我们看到定义颜色时使用的操作符是k,而不是我们以前使用的rg操作符。这是因为这里使用的是 CMYK (Cyan、Magenta、Yellow、Key) 颜色空间,而rg是用来定义rgb颜色空间的。而 CMYK 需要四个颜色值。关于颜色空间的内容我们将在后面讲解。透明度关于透明度,我觉得这里不需要特别解释它的概念。不透明度使用 /CA(填充不透明度) 或者 /ca(描边不透明度) 。需要注意的是PDF渲染引擎在计算最终的颜色时,先计算混合模式的值,之后根据透明度再计算一个最终的颜色。假设不透明度是 $\alpha$ , 混合模式计算的值是 $C_blend$ ,最终的颜色可以使用下列公式计算$C_final = \alpha \times C_blend + (1-\alpha) \times C_blackground$我们仍然采用上面的例子,只不过这一次新增一个不透明度,我们将不透明度定为 0.52 0 obj << /Type /Page % 这是一个页面 /MediaBox [0 0 700 792] % 纸张尺寸为美国信肖像(612点x792点) /Contents [3 0 R] % 图形内容在对象4中 /Resources << /Font << /F1 5 0 R % 注册字体信息 >> /ExtGState << /GS_Alpha50 <</CA 0.5 /ca 0.5>> /GS_Normal <</BM /Normal>> /GS_Multiply <</BM /Multiply>> /GS_Screen <</BM /Screen>> /GS_Overlay <</BM /Overlay>> /GS_Darken <</BM /Darken>> /GS_SoftLight <</BM /SoftLight>> /GS_Difference <</BM /Difference>> /GS_Exclusion <</BM /Exclusion>> >> >> endobj我们这里定义了一个不透明度,可以在适当的时候调用。实际上我们调用字典中定义的属性本质上就是在使用图形状态操作符,一旦设置了后面如果没有手动变更,那么整个流中都会采用这个状态。3 0 obj % 页面内容流 << >> stream % 流的开始 0.8 0 0 0 k 100 150 400 400 re f %底色采用青色的矩形 /GS_Alpha50 gs %设置全局的不透明度 %======= Normal 模式============== /GS_Normal gs 0 0.9 0 0 k %字体颜色为洋红色 BT /F1 72 Tf 0 500 Td (Normal) Tj ET %====== Multiply 模式 ================== % ...... 按照上面的例子依次往下列举各个混合模式 endstream % 流结束 endobj这里我将它放到定义矩形之后、开始混合模式之前。最终得到的结果如下:放到其他位置显示的效果又不太一样,这个读者可以自行去尝试总结本节到此就结束了。本节中的不透明度和混合模式理解起来不难。虽然每种混合模式标准中都定义了详细的计算方式,但是我们并不是要开发PDF渲染引擎,本文只用来作为扫盲,只涉及一些大概的概念即可。关于混合模式的详细信息可以在PDF标准中的 关于 blend mode 模块中找到
2026年07月25日
9 阅读
0 评论
0 点赞
2026-07-25
读《失败的春秋》
我之前接触春秋时期的历史也就看过历史剧《东周列国志》,再加上学生时期学过的一些文言文。当初看电视剧的时候除了一些耳熟能详的剧情我能看懂,有些剧情对我来说还比较陌生,人物关系比较混乱,只是觉得这是一个孔子口中礼崩乐坏的时代,有国与国之间的吞并,有大臣和国君之间的残忍杀戮,有兄弟之间为了侯位自相残杀。读这本书给我科普了不少春秋时代的典故和一些文献中的习俗。春秋时代的开启周时代的政治格局是一个类似同心圆的结构,周天子的封地(或者说国家)在华夏的正中心位置,因为是位于所有诸侯国正中心,所以也叫做中国。外层靠近中心的位置是天子的近亲同姓人,地盘越往外越靠近边缘的与天子的关系就越远。而最边缘的是各种少数名族,或者从血缘上也是汉人但是不属于当时华夏地区的血脉。一般古书上将所有的边境地区的人统统追认为五帝之一的“颛顼”的血脉。那个时候蛮夷、夷狄的含义与我们现在也大不相同。春秋时期对位于不同方位的少数名族的称呼也不一样。一般称为:东夷、南蛮、西戎、北狄。因为周天子诞生的后代只能由嫡长子继承周天子的位置,而其他的同胞兄弟只能由周天子赐地建国,而天子的地盘有限所以天子的地盘越分越少,而那个时候少数民族与一些边缘化的国家接壤,而掠夺蛮夷、夷狄的地盘属于政治正确,所以周边的国家实力反而越来越强大,这么一升一降,随着时间的推移最终导致天子大权旁落,而诸侯国力日益增强。并且西周的周幽王因为少数民族入侵被杀,周平王东迁开启东周时代时还需要诸侯保护,此时周天子再也挑不起天下这个担子,变成了诸侯争霸的时代。春秋各国春秋时代有各种小国家,别看它们小,它们反而是周天子的近亲。春秋时期姬姓的国家有很多,可以根据位置进行划分。中原地区有:卫国:由周武王同母弟康叔建立,文化底蕴深厚,存续时间极长。曹国:由周武王之弟曹叔振铎建立,夹在晋、楚等大国之间艰难生存。虢(guó)国:分为东虢和西虢,曾长期担任周王室的卿士,后被晋国用“假道伐虢”之计消灭。随国(曾国):汉水流域最大的姬姓国,主要负责替周王室南方边疆防御楚国。相对偏远的地区有:晋国:由周成王之弟唐叔虞建立,是春秋中期最强大的姬姓国,长期担任中原霸主。鲁国:由周公旦之子伯禽建立,完美保留了周朝的礼乐制度,是当时的文化中心。郑国:由周宣王之弟郑桓公建立,春秋初期最早崛起并挑战周王室权威(“庄公小霸”)。燕国:由召公奭建立,位于北方边疆,是少数延续到战国末期的姬姓大国。吴国:由周太王长子太伯建立,位于江南,春秋晚期军事力量极强,曾攻破楚国。齐鲁地区齐国的起源是姜太公,因此齐国的国姓是姜。古时候女子是只有姓没有名,嫁人之后夫姓在前自己本家的姓在后,所以武姜其实本家姓姜夫家姓武。另外古时候兄弟几人称为伯、仲、叔、季。但是并不是严格按照这个来划分,嫡长子称为伯、次子为仲、小儿子称为季、其他小于二子大于次子的统称为叔。所以刘邦之前的名字叫刘季,并不是因为他是第四个孩子,而是因为他是小儿子。而刘太公当初三个儿子,所以刘季也就是我们今天所说的“刘三”。只有嫡长子能称之为伯,而如果长子是庶出的话,长子被称为孟。孙伯符从名字上看是嫡长子,而曹孟德听起来就是庶出的长子。齐国是异姓功臣封国,地理位置临海,土地盐碱化严重,不适合死板地种地。姜太公采取了非常现实的政策——“因其俗,简其礼”,大搞通商工官、发展盐铁之利,极其重实效。因此齐国的民风相对礼仪之邦来说比较开放。齐国的女子也比较热情奔放。所以后世称呼美女也喜欢用齐姜这个词,有时候也简称姜。所谓的孟姜女的典故似乎出现在汉代以后,孟姜女可以解释为姜家长女,也可以解释为一个美丽的长女。鲁国是周室宗亲的“亲儿子”,承担着向东夷传播周礼、拱卫周室的政治任务。伯禽到了鲁国,搞的是“变其俗,革其礼”,强行推行西周的周礼和宗法制度。相比于齐国的国风来说,鲁国相对保守,比较死板。这样的结果就是鲁国日益衰落。而齐国能快速崛起。而《春秋》这本史书主要记录鲁国的历史。这本书也比较重视礼法,它采取的原则是非礼勿言,不符合礼法的事不记录或者换一种符合礼法的表现手法。后世也有春秋笔法的说法。卫、宋、郑武王伐纣成功之后建立周朝。他并没有对商人赶尽杀绝,而是采用优待的政策。将商人后代封到了宋国。但是他对商人也不能完全放心,因此将自己的两个弟弟封到了宋国附近建立了郑国和卫国。卫国建立在原商朝国都朝哥之上,周王原本打算用新时代风气来感化原商朝臣民。但是最终却被反噬,形成了奢靡的享受之风。卫国的地理环境不好,经常受到少数民族的袭扰,但是卫国又沉迷享受,在整个春秋时期卫国都没有值得记录的成就。后人回首历史,把卫国的情歌当作国家命运多舛的重要原因,甚至传说这些音乐是商纣时代留下来的诅咒。纣的一个乐师叫师延,为纣王作了“靡靡之乐”。因为它太小了,而且也那么的默默无闻,秦始皇扫六合的时候居然把它忘了,它最终在整个战国时期是最后灭亡的。宋国人因为是商人的后代,从一开始就不受待见,称为最早地域歧视的受害者。先秦诸子编笑话,编预言故事时,主角的蠢人几乎都是宋人。宋国蠢人蠢事虽多,基本特征却是对环境不敏感,头脑一根筋。至于那种好耍小聪明,弄巧成拙的蠢,例子就很少见。而郑国相对来说比较辉煌,郑庄公也被称为春秋小霸主。郑国最著名的故事应该就是“郑伯克段于鄢”。以及射向周天子的那一箭。郑国、卫国相邻,风俗有不少相似的地方,所以往往被相提并论。提到姑娘时髦漂亮,则说郑卫之女;说到音乐淫靡动听,则说郑卫之音争霸春秋初期的战争,大致有这么几个特点:第一,军队规模不大;第二,持续时间不长;第三,不防守战略要地;第四,不以摧毁敌军有生力量为目标。那个时候两军对垒,双方战车互相冲锋,每次交锋叫做一回合。然后调转车头再次冲锋、交锋。一般几个回合就分出胜负了,所以一般打仗很少超过一天的。车战是很讲究规则的,竞技体育的意味很浓。要打仗,两国约好到一片开阔地去打,不能不宣而战,更不许偷袭。但求分出胜负,不求制敌死命,甚至据说连追击失败的敌人可以追击多远,周礼都有规定。所以基本没怎么出现战国时期那种大规模的、持续时间几个月甚至几年的战争,也没有出现坑杀降将的恶劣事件。周天子大权旁落无法挑起担子,但是这种集中各诸侯国办大事的活动总归需要有人来主持,而能参与主持这种活动的人就是所谓的霸主。就是说霸主有三个特点:第一,他有牺牲精神;第二,他能为诸侯解决困难;第三,他是天子意志的执行者。换言之,天下是天子的,但是现在天子不管事,事情归霸主管。而最早的霸主公认的应该是齐桓公。他打的旗号是“尊王攘夷”,经常联合诸侯解决边境的少数名族问题。此时霸主的主要责任是联合诸侯,但是为了维持这个联盟也需要霸主自身设施一点好处给周边的小国。各盟友国之间也讲基本的道义。但是齐国衰落,晋国成为霸主之后这条人道注意的底线就丢失了。晋国干了不少当时看来无底线的事情。一个是强迫盟友上供,向盟友要钱要物,另一个就是晋国干了不少灭国的事情,最典型的就是假道伐虢、唇亡齿寒的故事。楚国一直是中原南部的大国,随着楚国国力日益强盛,而中原各国因为战争内耗严重时,楚国的威胁也就越来越大,而楚国也有问鼎中原的野心,在争霸的路上楚国和各大国之间早晚有一战。其中晋楚之间打仗最多。而夹在两个大国中间的小国就比较悲催了,两大国家经常因为争夺这些小兄弟而对它们轮番揉拧。今天投靠楚国,明天晋国以此为理由讨伐。再投降晋国之后,楚国又以此为理由讨伐。因此中原各国战事不断。贵族的落寞此时的春秋有点像欧洲的中世纪,你中有我我中有你。这国的国君可能有那一国的血脉,或者国君的妻子是另一个国君的姐妹或者女儿。贵族也有贵族的气派。士阶级以上的贵族子弟,从小学习任务都很繁重。武艺方面,要掌握长短兵器使用的技巧,尤其是要精通射箭和驾驭马车:文化方面,除了基本的读写和计算能力以外,据说是“春秋教以礼乐,冬夏教以诗书”,按照《礼记•内则》的说法,要学习到四十岁才可以出来当官。一个成年贵族需要做到“九能”:建邦能命龟:龟命是占卜的意思,也就是说建国时会讨吉利田能使命:能有基本的军事素养作器能铭: 会写正面报道,能弘扬时代正能量使能造命: 出使外国能完成君主的使命升高能赋: 登高临远,旅游时能吟诗作赋师旅能誓:打仗的时候能鼓舞士气,做战前演讲山川能说: 对本国名山大川,熟悉到能做导游。山川既可以是祭祀对象,往往也是战略要地,它们的门道要能说清楚丧纪能诔:会写悼词祭祀能语:祭祀时是构建祖先认同民族凝聚力的重要时刻,能主持祭祀,还能准确的向神明和祖先汇报工作贵族聚会并不是胡侃吹牛,而是要引经据典,甚至要歌唱诗歌,这里的诗歌主要是《诗经》,贵族会根据场合来歌颂诗经中的不同诗句来表达感情。所以难怪,孔子要教育儿子“不学《诗》,无以言”,这不是说不懂《诗》连话都不会说了,而是说不懂《诗》的话,混不进贵族的社交圈。世界历史上有个很普遍的现象,就是当贵族阶级的特权受到冲击、实力下降时,贵族风度就得到特别的重视,往往也因此别有魅力。春秋时期社会上最高效的方式就是封建君主一言堂,一人独揽大权,君主需要的是没有自己思想的提线木偶或者说办事机器。而封建贵族则讲究贵族范,贵族有自己的行事主张和自己的价值观。因此国君与贵族的矛盾也越来越深,各个曾今的霸主国似乎都不约而同的陷入各种内乱。有国君与贵族之间的杀戮,有贵族与贵族之间的杀戮。这个时期有著名的赵氏孤儿,魏、赵、韩三家分晋,也有田氏代齐。而最终在完成君主与贵族统一意志之后,正式进入战国时代。战国时代更加混乱,国家组织能力进一步提升,同时战争规模更大,过程也更加残酷,持续时间也更长久。
2026年07月25日
2 阅读
0 评论
0 点赞
2026-07-19
PDF 标准详解(七)——参数字典
在前面的文章中,我们提到,PDF支持5种基本数据结构和三种复杂结构,简单数据结构分别是:数字、字符串、名称、bool值、null对象。而复杂类型有数组、字典和流;在线型那一篇博文我们看到了数组的使用,它非常的简单,一般来说它的使用与其他编程语言中数组的使用并没有什么特别的地方。随着文档内容的复杂化,如果每出现一个显示的元素都要使用操作符详细的定义它的位置、颜色、填充等属性那么着实有点复杂了。对于这种重复型的操作,编程语言中一般使用函数或者类来进行封装,而PDF中这种重复性的定义则使用字典来封装。我们在前面的一系列示例中看到过别的数据类型的使用。目前对于简单类型都已经很清楚它的使用了,但是对于复杂的类型我计划是拆解pdf标准的内容,在合适的时候带入pdf数据结构的内容。所以这一篇我们先从字典开始语法我们先来了解一下它的语法。如果你熟悉一些编程语言(Python、C/C++、Java、Go等等),那么一定对字典的概念不会陌生。字典是由一组键值对组成,可以通过键快速的查到它对应的值。PDF 的字典本质上也是一个无序的键值对容器。但在PDF中,它的语法有两个极其鲜明的“硬核”特征:尖括号护体:字典必须由成对的双左尖括号 << 开始,并由双右尖括号 >> 结束。斜杠开路:字典里的 “键(Key)” 必须是 PDF 中的 Name(名称) 类型。这意味着它不需要加任何引号,而是直接以斜杠 / 开头。而 “值(Value)” 则可以是 PDF 支持的任意数据类型。下面是一个具体的例子:<< /Type /XObject /Subtype /Image /Width 640 /Height 480 >>上面的这个字典是由 一对 "<<" ">>" 包裹起来的。它有四个键值对。重要的 /TypePDF的字典和编程语言中的字典有点不一样。编程语言中的字典纯粹是为了快速的进行查询,它可以形容任何属性,而编译器也不关心这个字典的作用。但是PDF中不太一样,有时候PDF渲染器会读取字典来进行一些渲染工作,那么它就需要知道这个字典是用来干什么的。我们可以通过 /Type 来指明这个字典的作用,例如前面的例子中:1 0 obj % 对象1 << /Type /Pages % 这是一个页面列表 /Count 1 % 只有一页 /Kids [2 0 R] % 页面对象编号列表。这里只是对象2 >> endobj % 对象1结束表示这是一个页面列表的字典。渲染器读到这里的时候知道这个文档有一个页面。页面的内容对应着对象2。此时渲染器再去读取对象2,发现它又是一个字典2 0 obj << /Type /Page % 这是一个页面 /MediaBox [0 0 612 792] % 纸张尺寸为美国信肖像(612点x792点) /Contents [3 0 R] % 图形内容在对象4中 >> endobj在对象2 这个字典中,我们定义了它的/Type 为 /Page 表示它是一个页面对象。它的内容在对象3中定义。读到对象3时发现它又是一个字典:3 0 obj % 页面内容流 << >> stream % 流的开始 0.5 0.5 0.5 RG 20 w [1 2 5 7 8 1 4 5 12 4 5] 0 d 100 100 m 400 100 l S endstream % 流结束 endobj3终于不是字典了。它是一个流对象(又出现新的概念了)。示例上一节中我们写了一个复杂的示例:0.5 0.5 0.5 RG 20 w [1 2 5 7 8 1 4 5 12 4 5] 0 d 100 100 m 400 100 l S在这个里面我们定义了线宽、虚线类型,然后进行了划线。在这个简单的示例中这么做完全没问题,但是对于一个复杂的PDF文档来说,页面中的内容可能不止一个流对象,而且也不止一个页面。如果我们将它作为条形码在每一页的关键位置使用,我们每一次都要写这么一个东西,而且如果某一天条形码变了,每个地方都要重写虚线的数组,那种工作量你碰到了可能会当场辞职。但是别担心,人类进步的一个重大原因是人类喜欢偷懒。对于这种重复无聊的工作有字典这个东西帮我们偷懒。这里我们需要用到 扩展图形状态字典 这么一个东西。 对于上面一个关于虚线的封装,我们可以定义下面这样一个字典:6 0 obj << /LW 20 /D [ [1 2 5 7 8 1 4 5 12 4 5] 0] % 封装虚线模式、等效于 [1 2 5 7 8 1 4 5 12 4 5] 0 d >>那么我们怎么用它呢?在使用之前需要先在页面对象的资源字典中进行注册。我们修改一下2这个对象2 0 obj << /Type /Page % 这是一个页面 /MediaBox [0 0 612 792] % 纸张尺寸为美国信肖像(612点x792点) /Contents [3 0 R] % 图形内容在对象4中 /Resources << /ExtGState << %注册图形状态字典 /MyDashStyle 6 0 R % 绑定到对象6 >> >> endobj接着我们就可以在内容流中使用它3 0 obj % 页面内容流 << >> stream % 流的开始 0.5 0.5 0.5 RG /MyDashStyle gs 100 100 m 400 100 l S endstream % 流结束 endobj需要注意一点,我们需要在使用图形状态字典的后面加上 gs 操作符,它表示 Graphics State,调用这个资源字典。你可以将它理解为调用函数的call指令消失的 /Type我们说 /Type 是很重要的内容,它可以告知渲染器这个字典是什么类型的字典。但是我们定义的字典似乎没有Type,它能正确的渲染,这也就说明 /Type 并不是必须的,PDF渲染器可以直接的(从Type中读取)或者间接的(自行推导)得知类型并根据类型进行渲染。我们可以尝试一下将之前例子中所有 /Type 都去掉,我们发现PDF渲染器仍然能正常工作。我们可以结合之前所说的PDF的渲染过程来理解。首先渲染器会读取交叉引用表的内容。交叉引用表中 /Root 对应着4这个对象,此时渲染器知道了,4对象的Type为 Catalog4 对象 /Pages 键对应着1这个对象,渲染利用这个信息可以推导出 1 对象是一个 pages 类型1 对象中 /Kids 中有一个引用的子对象,就是2对象,所以2对象就是一个 /Page2 对象中,注册了一个 资源字典 6 对象,那么6对象肯定是资源字典了。也就是说PDF对象可以根据上下文来推导出具体的类型,我们实际上并不需要每个都指定。而且推导的类型的优先级是要高于手工指定的,读者可以自行做一个实验,如果我们将6对象添加一个键值对 /Type /Pages 给出一个错误的类型,渲染器也不会报错,仍然可以正常工作。自定义自己的键上面我们看到定义字典中的键都是PDF内置的键,比如用来定义线宽的 /LW ,定义虚线的 /D。这里是不是说键一定是PDF内置的呢?其实不然,我们当然可以定义自己的键。例如,读者可以自己对上面的资源字典做一些修改。加入一些自定义的键。PDF的标准中并没有规定字典的键。但是在PDF渲染器进行渲染时它会忽略不认识的键,只读取它认识的。一个是为了方便后面对标准进行扩展,另一个也是方便各大PDF渲染器的厂商能定义一些自己的扩展。字典的查找顺序这里我们将字典注册到了 Page 对象中。是不是意味着只能放到Page中进行注册呢?答案是否定的。实际上我们可以在解析过程中的任意位置进行注册。也就是对于多页公用的样式,我们可以注册到 /Pages 这个共同的父节点中%PDF-1.0 % PDF 版本号为 1.0 的文件头 1 0 obj % 对象1 << /Type /Pages % 这是一个页面列表 /Count 2 % 只有2页 /Kids [2 0 R 8 0 R] % 页面对象编号列表。 /Resources << /ExtGState << %注册图形状态字典 /MyDashStyle 7 0 R % 绑定到对象7 >> >> endobj % 对象1结束 2 0 obj << /Type /Page % 这是一个页面 /MediaBox [0 0 612 792] % 纸张尺寸为美国信肖像(612点x792点) /Contents [3 0 R] % 图形内容在对象4中 /Resources << /ExtGState << %注册图形状态字典 /MyDashStyle 6 0 R % 绑定到对象6 >> >> endobj 3 0 obj % 页面内容流 << >> stream % 流的开始 0.5 0.5 0.5 RG /MyDashStyle gs 100 100 m 400 100 l S endstream % 流结束 endobj 4 0 obj << /Type /Catalog %文件目录 /Pages 1 0 R %参考页面列表 >> endobj 6 0 obj << /Type /ExtGState /LW 20 /D [ [1 2 5 7 8 1 4 5 12 4 5] 0] % 封装虚线模式、等效于 [1 2 5 7 8 1 4 5 12 4 5] 0 d >> 7 0 obj << /Type /ExtGState /LW 20 /LC 1 >> 8 0 obj << /Type /Page % 这是一个页面 /MediaBox [0 0 612 792] % 纸张尺寸为美国信肖像(612点x792点) /Contents [9 0 R] % 图形内容在对象9中 >> endobj 9 0 obj % 页面内容流 << >> stream % 流的开始 0.5 0.5 0.5 RG /MyDashStyle gs 100 100 m 400 100 l S endstream % 流结束 endobj xref %这里我们跳过了交叉引用表的开始 0 5 trailer << /Size 5 %交叉引用表的行数 /Root 4 0 R % 参考文档目录 >> startxref 0 %xref表开始的字节偏移量,这里设置成0 %%EOF这里我们在第一页的 /Page 对象和对应的父对象 /Pages 中都注册了一个 /MyDashStyle 的资源数组。一个是虚线一个是实线。我们可以看到第一页显示为虚线,第二页显示为实线。也就是在第一页中发生的名称覆盖的现象。如果有一点编程语言的知识就很容易理解这个概念,它就相当于函数内部局部变量对全局变量的覆盖。总结最后我们补充一下关于图形状态字典的一些键,它们的作用与我们之前介绍的线型的操作符一一对应,作用也是一样的操作符对应的键值wLWJLCjLJdD
2026年07月19日
5 阅读
0 评论
0 点赞
1
2
...
40