漂亮的印刷HTML
这是最初发布在我的博客上的文章的转载,但几经成熟……。 仅进行了微小的格式更改。 它也是系列文章的一部分,该系列旨在从基本原理出发,涵盖服务器端Swift的基础。 请考虑查看该系列中的其余内容:
- Swift中的类型安全HTML
- 在Swift中渲染HTML DSL
- Swift中的可组合HTML视图
在上一篇文章中,我们通过递归遍历节点树并渲染每个原子单元来实现朴素的HTML渲染器。 该实现非常简单,并且由于没有换行符或空格来使结果更具可读性,因此它生成了HTML的“精简版”。 有时我们想要产生一个易于理解的字符串表示形式,例如,在服务器的开发模式下,具有格式良好的HTML可能会有所帮助,并且对文档的“快照测试”很有用。
“漂亮打印”是获取一条数据并将其打印到某种意义上在美学上令人愉悦的字符串的行为。 例如,HTML是组成文档的节点树,并且可以通过多种方式将其打印为字符串:
Hello world
Hello world
Hello world
Hello world
Hello world
这些打印中的每一个都代表相同的HTML文档,但最后一个最容易采用。但是,有时仅在节点上添加换行符和选项卡是不够的。 一行可能很长,您可能希望在流过特定页面宽度后换行。 例如,以下文档已经打印精美,以确保没有一行超过40个字符:
Articles about math, functional
programming and the Swift
programming language.
注意,它会跟踪缩进,以使每行的第一个字符匹配。 它甚至可能变得更加复杂! 例如,标记的属性可能会变得很长,并且当您包装它们的值时,您要确保它们对齐在一起:
<body id="home"
style="background: #fff;">
Articles about math, functional
programming and the Swift
programming language.
注意style
如何与上面的id
对齐,而不是简单地从<body
缩进两个空格。 如果所有属性不能全部放在一行上,我们甚至可以通过要求所有属性都在换行符上来增加另一层复杂性:
<body id="home"
style="background: #fff;">
Articles about math, functional
programming and the Swift
programming language.
为了使事情变得更加有趣,我们还要求如果class
或style
值超出页面宽度,那么它们的值将进一步分成多行:
<body id="home"
style="background: #fff;
color: #333;">
Articles about math, functional
programming and the Swift
programming language.
现在很漂亮! 这对于在测试中为您的页面拍摄快照非常有用,然后由于自由使用新行,因此对文档的任何更改都易于显示为差异。 在这种情况下,一个40列的页面用于在小文档上显示漂亮的打印,但是通常您会使用更标准的东西,例如80或110。
为了进行这种复杂的漂亮打印,我们将使用布兰登·凯斯(Brandon Kase)编写的名为DoctorPretty的精美小程序库。 它是基于Philip Wadler于20年前于1997年写的一篇奇妙的文章,称为“更漂亮的打印机”!
这个想法是使用一些描述文档行流向的组合器将您的数据结构(在我们的示例中为Node
)转换为Doc
数据结构。 然后,漂亮的打印机负责将Doc
转换为格式正确的字符串。 我们不会深入探讨DoctorPretty的内部原理,因此鼓励您阅读其文档和测试。
我们的任务是实现以下功能:
func prettyPrint (node: Node ) -> Doc {
???
}
我们的Node
类型是一个带有element
和text
大小写的枚举,因此我们可以填写该部分:
func prettyPrint (node: Node ) -> Doc {
switch node {
case let .element (element):
???
case let .text (text):
???
}
}
让我们暂时假设我们有两个假设函数prettyPrint(element:)
和prettyPrint(text:)
知道如何打印这些片段,那么我们有:
func prettyPrint (node: Node ) -> Doc {
switch node {
case let .element (element):
return prettyPrint (element: element)
case let .text (text):
return prettyPrint (text: text)
}
}
现在,真正的工作就是实现这些功能!
text
节点最容易处理,所以让我们开始吧。 文本节点可以填满整行直到页面宽度,然后回绕到文本节点的起始位置,但换行。 我们不能在任何时候将文本分成新行,它应该发生在文本的空白处。 因此,我们可以通过分割空间和映射来构建Doc.text
值数组:
let textParts: [ Doc ] = text
.split (separator: " ")
. map { Doc.text ( String ($0)) }
现在,我们有了一个Doc
值数组。 在这里,我们可以使用DoctorPretty组合器来决定这些文本部分在文档中的布局方式。 有一个名为fillSep
的函数可对Doc
值序列如下的Doc
值进行操作:
/// Concats all horizontally until end of page
/// then puts a line and repeats
public func fillSep () -> Doc
这正是我们想要的! 因此,我们可以将函数填写为:
func prettyPrint (text: String ) -> Doc {
return text
.split (separator: " ")
. map { Doc.text ( String ($0)) }
.fillSep ()
}
我们可以给它一个非常简单的测试驱动。 从DoctorPretty中获取字符串的第一步是首先调用renderPretty(ribbonFrac:pageWidth:)
,该方法返回中间的SimpleDoc
值。 参数ribbonFrac
确定允许由非缩进字符组成的行的百分比,而pageWidth
确定页面的宽度。 然后,在SimpleDoc
上调用displayString
以最终得到一个字符串:
prettyPrint (text: "Articles about math, functional programming and the Swift programming language.")
.renderPretty (ribbonFrac: 1, pageWidth: 40)
.displayString ()prettyPrint (text: "Articles about math, functional programming and the Swift programming language.")
.renderPretty (ribbonFrac: 1, pageWidth: 40)
.displayString ()Articles about math, functional
programming and the Swift programming
language.Articles about math, functional
programming and the Swift programming
language.
暂时还没有给人留下深刻的印象,但是仍然很高兴我们要做到那儿很少的工作!
element
节点是所有复杂性所在。 我们将把它分解成许多小辅助函数。 我们从它的定义开始:
func prettyPrint (element: Element ) -> Doc {
???
}
此功能的工作是为open标签,所有带有缩进的子项,closed标签构造一个Doc
值,然后将它们全部堆叠。 假设我们已经有了以下辅助函数:
func prettyPrintOpenTag (element: Element ) -> Doc {
???
}func prettyPrintChildren (nodes: [ Node ]?) -> Doc {
???
}func prettyPrintCloseTag (element: Element ) -> Doc {
???
}
请注意, Element
children
是可选数组,因为某些节点不能具有子代,例如img
。
事实证明Doc
构成一个monoid,并且操作使文档一起流动,它们之间没有空格或中断。 因此,我们可以根据助手来编写
prettyPrint(element:)
:
func prettyPrint (element: Element ) -> Doc {
return prettyPrintOpenTag (element: element)
prettyPrintChildren (nodes: element . children)
prettyPrintCloseTag (element: element)
}
现在我们只需要渲染这三个部分就可以了!
好吧,说起来容易做起来难! 还有很多工作要做。 呈现一个开放标签包括节点的名称(例如<body
),它的属性,然后可能取决于子节点的>
和换行符。 假设我们已经有了一个可以处理属性数组的prettyPrint(attributes:)
函数,那么我们的开放标签函数可以写为:
func prettyPrintOpenTag (element: Element ) -> Doc {
return .text ("<")
.text (element . name)
prettyPrint (attributes: element . attribs)
.text (">")
(element . children == nil ? . empty : . hardline)
}return .text ("<")
.text (element . name)
prettyPrint (attributes: element . attribs)
.text (">")
(element . children == nil ? . empty : . hardline)
}
其中最复杂的部分是我们如何检查children
是否nil
以确定是否需要打印换行符,以便将子代打印在标签内。 在children
nil
的情况下,我们可以使用empty
文档来表示无事可做。
接下来,我们需要实现此功能:
func prettyPrint (attributes attribs: [ Attribute ]) -> Doc {
???
}
暂时假设我们已经有一个函数prettyPrint(attribute:)
用于呈现单个属性。 然后,我们可以使用该函数map
属性以生成Doc
值数组。 我们希望将这些值连接在一起,以便在适合时将它们打印在一行上,否则将它们全部打印在换行上。 有一个专门用于此目的的组合器!
/// Concat all horizontally if it fits, but if not
/// all vertical
public func sep () -> Doc
现在,如果仅将sep()
应用于文档数组,则当属性流到换行符时,它将返回到open标签的当前缩进。 但是,我们希望所有属性都对齐。 有一个很棒的组合器叫做hang
,它正是这样做的! 它需要一个参数来决定要添加多少个额外的缩进,但在我们的示例中,我们需要0
。
最后一个微妙的细节,然后我们才能写出prettyPrint(attributes:)
。 如果有要渲染的属性,我们希望在文档前添加一个空格以将其与标记隔开。 这使我们拥有
而不会意外地执行
。 因此,最终的实现是:
func prettyPrint (attributes attribs: [ Attribute ]) -> Doc {
return .text (attribs . count == 0 ? "" : " ")
attribs
.map ( prettyPrint (attribute:))
.sep ()
.hang (0)
}return .text (attribs . count == 0 ? "" : " ")
attribs
.map ( prettyPrint (attribute:))
.sep ()
.hang (0)
}
最后,我们为单个属性实现帮助程序prettyPrint(attribute:)
:
func prettyPrint (attribute: Attribute ) -> Doc {
return .text ("\(attribute . key)=\"\(attribute . value)\"")
}
渲染子节点依赖于递归调用prettyPrint(node:)
函数,还有一些小附加功能。 首先,如果children数组为nil
(对于不支持nil
的标签),那么我们只想返回空文档,以便不进行任何格式化:
func prettyPrintChildren (nodes: [ Node ]?) -> Doc {
guard let nodes = nodes else { return . empty }func prettyPrintChildren (nodes: [ Node ]?) -> Doc {
guard let nodes = nodes else { return . empty }???
}???
}
要填充其余部分,我们可以在节点上进行map
,对每个节点进行vcat
,然后应用vcat
运算符获取单个文档,其中所有子级都垂直堆叠。 我们还应用了indent
运算符,以确保子代在其父标记内缩进:
func prettyPrintChildren (nodes: [ Node ]?) -> Doc {
guard let nodes = nodes else { return . empty }func prettyPrintChildren (nodes: [ Node ]?) -> Doc {
guard let nodes = nodes else { return . empty }return nodes .map ( prettyPrint (node:))
.vcat ()
.indent (2)
}return nodes .map ( prettyPrint (node:))
.vcat ()
.indent (2)
}
呈现结束标签时,我们需要确保对不能具有子节点的标签不做任何事情:
func prettyPrintCloseTag (element: Element ) -> Doc {
return element . children == nil ?
. empty
:
. hardline .text ("</") .text (element . name) .text (">")
}
在这里,我们确保在children
为nil
的情况下不做任何事情,否则,我们转到换行符并打印结束标记。
现在,我们已经完成了基本漂亮打印机的实现! 这是我们已完成工作的演示,其中红线对应于文档的页面宽度:
我们能够仅使用DoctorPretty库中的几个简单组合器就能实现这种高级打印机的功能,这真是令人惊讶。 这证明了通过代数性质的抽象能力。 我强烈建议阅读Wadler的原始纸,一台更漂亮的打印机。
1.)通过引入Config
类型来使漂亮打印可配置,该Config
类型植入了prettyPrint(node:config:)
并允许以下自定义:
- 页面宽度
- 缩进的空格数
- “悬挂样式”,即允许选择在所有属性都换行之前加入/退出将所有属性拟合在一行上的“全有或全无”策略
- 选择启用/停用以对齐属性键/值对的
=
。
2)在引言中我们提到,如果style
和class
属性的值不适合一行,则可以将其拆分为新行,但实际上并未实现。 添加额外的逻辑到prettyPrint(attribute:)
以允许这样做。 任何其他可以从中受益的属性?
- 漂亮的印刷品
- 漂亮的打印机
- 快照测试
- 漂亮医生
- 代数结构和协议