这是书上第一个项目,看起来简单但很具有挑战性。

项目描述

这个项目是要实现对文本文件中的标签进行转换为 HTML 格式。
在编写原型前,先定义一些程序要实现的目标:

  • 输入无需包含人工编码或标签
  • 程序需要处理不同的文本块以及内嵌文本
  • 很容易对其进行扩展,以支持其它标记语言

以上需求对于新手来说一眼过去感觉很简单,尤其是用惯了 Shell ,无法就是搜索替换的工作。

所需技能

罗列以下编写这个程序需要用到的知识点:

  • 读写文件,从标准输入读取和使用 print 输出
  • 迭代输入行
  • 字符串方法
  • 生成器
  • re 模块

初步实现

初步实现总共可以分为两步:找出文本块和添加标记。

找出文本块

在 HTML 格式中,文本块通常需要使用 <p></p> 进行标记,所以需要找出相应的文本块,文本块可能是一行也可能是多行,界定的标准应该是是否存在空行,如果存在空行则代表文本块到此结束。

实现文本块生成器 util.py 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
def lines(file):
for line in file: yield line
yield '\n'

def blocks(file):
block = []
for line in lines(file):
if line.strip():
block.append(line)
elif block:
yield ''.join(block).strip()
block = []

方法 lines 中,获取传递过来的 file 参数,然后进行迭代每一行,并且在最后添加了一个空行,这是为了后续处理时,判断结束的标识。

blocks 中,定义了 block 列表,这个列表存放文本块,文本块来源于 lines 中的生成器迭代出来每一行内容,并且去除文本块前后的空格。
一旦遇到空行表明出现了文本块,此时 if 条件不成立,将会进入到 elif 中,这里再此使用生成器,将前面所有行转换为一个字符串,最后将 block 列表清空。

哇,这里的思路是迭代文件中的每一行,直到出现了空行,然后将前面迭代的行的内容转换为一个字符串,从而实现了找出段落的功能。blocks 中使用生成器的原因是为了方便后续对每一个 block 进行标记,后面只需对 blocks 进行迭代即可。

添加一些标记

为了实现基本功能,可以先创建一个简单的标记程序,基本步骤有:

  • 打印一些起始标记
  • 对于每个文本块,在段落标签内打印
  • 打印一些结束标记

一个简单的标记程序 simple_markup.py 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import sys, re
from util import *

print('<html><head><title>...</title><body>')

title = True
for block in blocks(sys.stdin):
block = re.sub(r'\*(.+?)\*', r'<em>\1</em>', block)
if title:
print('<h1>')
print(block)
print('</h1>')
title = False
else:
print('<p>')
print(block)
print('</p>')
print('</body></html>')

在上述示例中,实现了对特定的字符的标记转换,以及通过定义布尔值的方式,将第一个文本块设置为 title ,虽然能实现内容,但是不好扩展,如果要扩展意味着要写非常多的 if 语句,且不好处理一个文本块中出现多种标记,嵌套的问题。

再次实现

为了提供可扩展行,需要提升程序的模块化程度,可以采用面向对象设计。
一些潜在可以设计为面向对象的组件:

  • 解析器:添加一个读取文本并管理其它类的对象
  • 规则:对于每种文本块,都指定一条相应的规则,能够检测不同类型的文本块,并相应的设置其格式
  • 过滤器:使用正则表达式来处理内嵌元素
  • 处理程序:供解析器用来生成输出

处理模块

处理模块实现了两个功能,第一个功能是提供 HTML 开头和末尾的固定输出,第二个功能是实现了一个友好的接口,方便调用特定标签的起始和结束的输出。

handlers.py 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class Handler:
"""
对 Parser 发起的方法调用进行处理的对象

Parser 将对每个文本 block 调用访问 start() 和 end(),
并将合适的文本 block 名称作为参数。
方法 sub() 将用于正则表达式替换,使用诸如 emphasis 等名称调用时,
这个方法将返回相应的替换函数
"""
def callback(self, prefix, name, *args):
method = getattr(self, prefix + name, None)
if callable(method): return method(*args)

def start(self, name):
self.callback('start_', name)

def end(self, name):
self.callback('end_', name)

def sub(self, name):
def substitution(match):
result = self.callback('sub_', name, match)
if result is None: match.group(0)
return result
return substitution

class HTMLRenderer(Handler):
"""
用于渲染 HTML 的具体处理程序

HTMLRenderer 的方法可通过超类 Handler 的方法 start() end() 和 sub()
来访问这些方法实现了 HTML 文档使用的标记
"""

def start_document(self):
print('<html><head><title>...</title></head><body>')

def end_document(self):
print('</body></html>')

def start_paragraph(self):
print('<p>')

def end_paragraph(self):
print('</p>')

def start_heading(self):
print('<h2>')

def end_heading(self):
print('</h2>')

def start_list(self):
print('<ul>')

def end_list(self):
print('</ul')

def start_listitem(self):
print('<li>')

def end_listitem(self):
print('</li>')

def start_title(self):
print('<h1>')

def end_title(self):
print('</h1>')

def sub_emphasis(self, match):
return '<em>{}</em>'.format(match.group(1))

def sub_url(self, match):
return '<a href="{}">{}</a>'.format(match.group(1), match.group(1))

def sub_mail(self, match):
return '<a href="mailto:{}">{}</a>'.format(match.group(1), match.group(1))

def feed(self, data):
print(data)

在 Handler 超类中实现了一个 callback 方法,通过 getattr 来获取对象中的方法,并检测方法是否能够被调用,一但该方法能被调用(即存在),则返回该方法,并附带所有传递进来的参数。
start 和 end 就是调用 callback 方法,并指定了相应的 prefix 。
sub 存在不同,在方法中定义了一个闭包 substitution 并传递了一个 match,在该函数内,调用 callback 实现调用 sub_ 前缀的方法,但在 sub 中,返回的是这个函数,意味着可以将这个函数作为替换函数,传递给 re.sub 。

接下来定义了一个子类 HTMLRenderer 在里面定义了所需标签的起始和结束所需的内容。
可以在命令行中进行测试:

1
2
3
4
5
6
7
8
9
>>> from handlers import HTMLRenderer
>>> handler = HTMLRenderer()
>>> handler.sub('emphasis')
<function Handler.sub.<locals>.substitution at 0x7fad9daeefc0>
>>> import re
>>> re.sub(r'\*(.+?)\*', handler.sub('emphasis'), 'This *is* a test')
'This <em>is</em> a test'
>>> re.sub(r'\*(.+?)\*', handler.sub('emphasis'), 'This is a test')
'This is a test'

可以看到,调用 sub 返回的是一个函数,随后用于 re.sub 中,成功的进行了标记的转换。如果没有匹配的话,返回 group(0) 也就是原始的内容。

规则模块

规则模块是供主程序(解析模块)使用的,主程序必须根据给定的文本块选择合适的规则来对其进行必要的转换。

规则模块必须具备的功能:

  • 知道适用于那种文本块(条件)
  • 对文本块进行转换(操作)
    因此每个规则对象都必须包含两个方法 condition 和 action 。

condition 只需要文本块这一个参数,返回一个布尔值,用于确认规则是否是否适用于该文本块。
action 也需要文本块作为参数,才能对文本块进行修改,同时需要能够访问处理模块中的对象。

rules.py 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class Rule:
"""
所有规则的基类
"""
def action(self, block, handler):
handler.start(self.type)
handler.feed(block)
handler.end(self.type)
return True

class HeadingRule(Rule):
"""
标题只包含一行,不超过 70 字符且不以 : 结尾
"""
type = 'heading'
def condition(self, block):
return not '\n' in block and len(block) <= 70 and not block[-1] == ':'

class TitleRule(HeadingRule):
"""
title 是文档中的第一个文本 block,前提条件是属于标题
"""
type = 'title'
first = True

def condition(self, block):
if not self.first: return False
self.first = False
return HeadingRule.condition(self, block)

class ListItemRule(Rule):
"""
列表项是以 - 开头的 block ,在设置格式过程中,需要将其删除
"""
type='listitem'

def condition(self, block):
return block[0] == '-'

def action(self, block, handler):
handler.start(self.type)
handler.feed(block[1:].strip())
handler.end(self.type)
return True

class ListRule(Rule):
"""
列表以紧跟在非列表项文本 block 后面的列表项打头
以相连的最后一个列表项结束
"""
type = 'list'
inside = False

def condition(self, block):
return True

def action(self, block, handler):
if not self.inside and ListItemRule.condition(self, block):
handler.start(self.type)
self.inside = True
elif self.inside and not ListItemRule.condition(self, block):
handler.end(self.type)
self.inside = False
return False

class ParagraphRule(Rule):
"""
段落是不符合其它规则的文本 block
"""
type = 'paragraph'

def condition(self, block):
return True

ListItemRule 重写了 action ,为了去除 - 。
ListRule 也重写了 action,用于确定标签从何处开始和何处结束,同时返回的布尔值始终为 False ,因为后续还要处理其它规则,意味着处理标签时的排序非常重要。

解析模块

解析模块 markup.py 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import sys, re
from handlers import *
from util import *
from rules import *

class Parser:
"""
Parser 读取文本文件,应用规则并控制处理程序
"""

def __init__(self, handler) -> None:
self.handler = handler
self.rules = []
self.filters = []

def addRule(self, rule):
self.rules.append(rule)

def addFilter(self, pattern, name):
def filter(block, handler):
return re.sub(pattern, handler.sub(name), block)
self.filters.append(filter)

def parse(self, file):
self.handler.start('document')
for block in blocks(file):
for filter in self.filters:
block = filter(block, self.handler)
for rule in self.rules:
if rule.condition(block):
last = rule.action(block, self.handler)
if last: break
self.handler.end('document')

class BasicTextParser(Parser):
"""
在构造函数中添加规则和过滤器的 Parser 子类
"""
def __init__(self, handler) -> None:
Parser.__init__(self, handler)
self.addRule(ListRule())
self.addRule(ListItemRule())
self.addRule(TitleRule())
self.addRule(HeadingRule())
self.addRule(ParagraphRule())

self.addFilter(r'\*(.+?)\*', 'emphasis')
self.addFilter(r'(http://[\.a-zA-Z/]+)', 'url')
self.addFilter(r'([\.a-zA-Z]+@[\.a-zA-Z]+[a-zA-Z]+)', 'mail')

handler = HTMLRenderer()
parser = BasicTextParser(handler)

parser.parse(sys.stdin)

在 Parser 类中定义了 addRule 和 addFilter 两个方法用于添加规则和过滤器。
随后在 parse 方法中对文本块进行规则和过滤器的迭代处理。

随后在子类 BasicTextParser 中实现了规则和过滤器的热插拔式的添加,注意顺序的问题,ListRule 是放在第一位的,避免 action 返回 True 导致循环退出。

这里面还有很多细节值得认真思考,真是一个非常好的动手实践项目。