-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathmudbrick.ex
More file actions
613 lines (488 loc) · 20.8 KB
/
mudbrick.ex
File metadata and controls
613 lines (488 loc) · 20.8 KB
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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
defmodule Mudbrick do
@moduledoc """
API for creating and exporting PDF documents.
## General example
Compression, OTF font with special characters, JPEG and line drawing:
iex> import Mudbrick.TestHelper # import some example fonts and images
...> import Mudbrick
...> new(
...> compress: true, # flate compression for fonts, text etc.
...> fonts: %{bodoni: bodoni_regular()}, # register an OTF font
...> images: %{flower: flower()} # register a JPEG
...> )
...> |> page(size: {100, 100})
...> |> image( # place preregistered JPEG
...> :flower,
...> scale: {100, 100}, # full page size
...> position: {0, 0} # in points (1/72 inch), starts at bottom left
...> )
...> |> path(fn path -> # draw a line
...> import Mudbrick.Path
...> path
...> |> move(to: {55, 40}) # starting near the middle of the page
...> |> line(
...> to: {95, 5}, # ending near the bottom right
...> width: 6.0, # make it fat
...> colour: {1, 0, 0} # make it red
...> )
...> end)
...> |> text(
...> {"CO₂", colour: {0, 0, 1}}, # write blue text
...> font: :bodoni, # in the bodoni font
...> font_size: 14, # size 14 points
...> position: {35, 45} # 60 points from left, 45 from bottom of page
...> )
...> |> render() # produce iodata, ready for File.write/2
...> |> then(&File.write("examples/compression_font_special_chars.pdf", &1))
Produces [this](examples/compression_font_special_chars.pdf).
<object width="400" height="215" data="examples/compression_font_special_chars.pdf?#navpanes=0" type="application/pdf"></object>
## Auto-kerning
iex> import Mudbrick.TestHelper
...> import Mudbrick
...> new(fonts: %{bodoni: bodoni_bold()})
...> |> page(size: {400, 100})
...> |> text(
...> [{"Warning\\n", underline: [width: 0.5]}],
...> font: :bodoni,
...> font_size: 70,
...> position: {7, 30}
...> )
...> |> render()
...> |> then(&File.write("examples/auto_kerning.pdf", &1))
Produces [this](examples/auto_kerning.pdf). Notice how the 'a' is underneath the 'W' in 'Warning'.
<object width="400" height="115" data="examples/auto_kerning.pdf?#navpanes=0" type="application/pdf"></object>
It's on by default, but we can turn it off:
iex> import Mudbrick.TestHelper
...> import Mudbrick
...> new(fonts: %{bodoni: bodoni_bold()})
...> |> page(size: {400, 100})
...> |> text(
...> [{"Warning\\n", underline: [width: 0.5]}],
...> font: :bodoni,
...> font_size: 70,
...> position: {7, 30},
...> auto_kern: false
...> )
...> |> render()
...> |> then(&File.write("examples/auto_kerning_disabled.pdf", &1))
Produces [this](examples/auto_kerning_disabled.pdf).
<object width="400" height="115" data="examples/auto_kerning_disabled.pdf?#navpanes=0" type="application/pdf"></object>
"""
alias Mudbrick.{
ContentStream,
Document,
Font,
Image,
Indirect,
Page,
Path,
TextBlock
}
@type context :: {Document.t(), Indirect.Object.t()}
@type coords :: {number(), number()}
@type colour :: {number(), number(), number()}
@doc """
Start a new document.
## Options
- `:compress` - when set to `true`, apply deflate compression to streams (if
compression saves space). Default: `false`
- `:fonts` - register OTF or built-in fonts for later use.
- `:images` - register images for later use.
The following options define metadata for the document:
- `:producer` - software used to create the document, default: `"Mudbrick"`
- `:creator_tool` - tool used to create the document, default: `"Mudbrick"`
- `:create_date` - `DateTime` representing the document's creation time
- `:modify_date` - `DateTime` representing the document's last update time
- `:title` - title (can change e.g. browser window title), default: `nil`
- `:creators` - list of names of the creators of the document, default: `[]`
## Examples
Register an OTF font. Pass the file's raw data.
iex> Mudbrick.new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
Register an image.
iex> Mudbrick.new(images: %{flower: Mudbrick.TestHelper.flower()})
Set document metadata.
iex> Mudbrick.new(title: "The best PDF", producer: "My cool software")
"""
@spec new(opts :: Document.options()) :: Document.t()
def new(opts \\ []) do
Document.new(opts)
end
@doc """
Start a new page upon which future operators should apply.
## Options
- `:size` - a tuple of `{width, height}`. Some standard sizes available in `Mudbrick.Page.size/1`.
"""
@spec page(Document.t() | context(), Keyword.t()) :: context()
def page(context, opts \\ [])
def page({doc, _contents_obj}, opts) do
page(doc, opts)
end
def page(doc, opts) do
Page.add(
doc,
Keyword.put_new(
opts,
:size,
Page.size(:a4)
)
)
|> contents()
end
@doc """
Insert image previously registered in `new/1` at the given coordinates.
## Options
- `:position` - `{x, y}` in points, relative to bottom-left corner.
- `:scale` - `{w, h}` in points. To preserve aspect ratio, set either, but not both, to `:auto`.
- `:skew` - `{x, y}`, passed through to PDF `cm` operator.
All options default to `{0, 0}`.
## Examples
iex> Mudbrick.new(images: %{lovely_flower: Mudbrick.TestHelper.flower()})
...> |> Mudbrick.page()
...> |> Mudbrick.image(:lovely_flower, position: {100, 100}, scale: {100, 100})
Forgetting to register the image:
iex> Mudbrick.new()
...> |> Mudbrick.page()
...> |> Mudbrick.image(:my_face, position: {100, 100}, scale: {100, 100})
** (Mudbrick.Image.Unregistered) Unregistered image: my_face
Auto height:
iex> Mudbrick.new(images: %{lovely_flower: Mudbrick.TestHelper.flower()})
...> |> Mudbrick.page(size: {50, 50})
...> |> Mudbrick.image(:lovely_flower, position: {0, 0}, scale: {50, :auto})
...> |> Mudbrick.render()
...> |> then(&File.write("examples/image_auto_aspect_scale.pdf", &1))
<object width="400" height="100" data="examples/image_auto_aspect_scale.pdf?#navpanes=0" type="application/pdf"></object>
Attempting to set both width and height to `:auto`:
iex> Mudbrick.new(images: %{lovely_flower: Mudbrick.TestHelper.flower()})
...> |> Mudbrick.page()
...> |> Mudbrick.image(:lovely_flower, position: {100, 100}, scale: {:auto, :auto})
** (Mudbrick.Image.AutoScalingError) Auto scaling works with width or height, but not both.
Tip: to make the image fit the page, pass e.g. `Page.size(:a4)` as the
`scale` and `{0, 0}` as the `position`.
"""
@spec image(context(), atom(), Image.image_options()) :: context()
def image({doc, _content_stream_obj} = context, user_identifier, opts \\ []) do
import ContentStream
case Map.fetch(Document.root_page_tree(doc).value.images, user_identifier) do
{:ok, image} ->
context
|> add(%ContentStream.QPush{})
|> add(ContentStream.Cm.new(cm_opts(image.value, opts)))
|> add(%ContentStream.Do{image_identifier: image.value.resource_identifier})
|> add(%ContentStream.QPop{})
:error ->
raise Image.Unregistered, "Unregistered image: #{user_identifier}"
end
end
@doc """
Write text at the given coordinates.
## Top-level options
- `:colour` - `{r, g, b}` tuple. Each element is a number between 0 and 1. Default: `{0, 0, 0}`.
- `:font` - Name of a font previously registered with `new/1`. Required unless you've only registered one font.
- `:position` - Coordinates from bottom-left of page in points. Default: `{0, 0}`.
- `:font_size` - Size in points. Default: `12`.
- `:leading` - Leading in points. Default is 120% of `:font_size`.
- `:align` - `:left`, `:right` or `:centre`. Default: `:left`.
Note that the rightmost point of right-aligned text is the horizontal offset provided to `:position`.
The same position defines the centre point of centre-aligned text.
- `:max_width` - Maximum width in points. When set, text will automatically wrap to fit within this width.
- `:break_words` - When `max_width` is set, whether to break long words that don't fit on a line. Default: `false`.
- `:hyphenate` - When `break_words` is enabled, whether to add hyphens at word breaks. Default: `false`.
- `:indent` - Indentation in points for wrapped lines. Default: `0`.
- `:justify` - Text justification when wrapping: `:left`, `:right`, `:center`, or `:justify`. Default: `:left`.
## Individual write options
When passing a `{text, opts}` tuple or list of tuples to this function, `opts` are:
- `:colour` - `{r, g, b}` tuple. Each element is a number between 0 and 1. Overrides the top-level option.
- `:font` - Name of a font previously registered with `new/1`. Overrides the top-level option.
- `:font_size` - Size in points. Overrides the top-level option.
- `:leading` - The number of points to move down the page on the following linebreak. Overrides the top-level option.
- `:underline` - A list of options: `:width` in points, `:colour` as an `{r, g, b}` struct.
## Examples
Write "CO₂" in the bottom-left corner of a default-sized page.
iex> import Mudbrick.TestHelper
...> import Mudbrick
...> new(fonts: %{bodoni: bodoni_regular()})
...> |> page()
...> |> text("CO₂")
Write "I am red" at 200, 200, where "red" is in red.
iex> import Mudbrick.TestHelper
...> import Mudbrick
...> new(fonts: %{bodoni: bodoni_regular()})
...> |> page()
...> |> text(["I am ", {"red", colour: {1, 0, 0}}], position: {200, 200})
Write "I am bold" at 200, 200, where "bold" is in bold.
iex> import Mudbrick.TestHelper
...> import Mudbrick
...> new(fonts: %{regular: bodoni_regular(), bold: bodoni_bold()})
...> |> page()
...> |> text(["I am ", {"bold", font: :bold}], font: :regular, position: {200, 200})
[Underlined text](examples/underlined_text.pdf?#navpanes=0).
iex> import Mudbrick
...> new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
...> |> page(size: {100, 50})
...> |> text([
...> {"the\\n", leading: 20},
...> "quick\\n",
...> "brown fox ",
...> {"jumps", underline: [width: 1]},
...> " over"
...> ], position: {8, 40}, font_size: 8)
...> |> render()
...> |> then(&File.write("examples/underlined_text.pdf", &1))
<object width="400" height="130" data="examples/underlined_text.pdf?#navpanes=0" type="application/pdf"></object>
[Underlined, right-aligned text](examples/underlined_text_right_align.pdf?#navpanes=0).
iex> import Mudbrick
...> new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
...> |> page(size: {100, 50})
...> |> text([
...> {"the\\n", leading: 20},
...> "quick\\n",
...> "brown fox ",
...> {"jumps", underline: [width: 1]},
...> " over"
...> ], position: {90, 40}, font_size: 8, align: :right)
...> |> render()
...> |> then(&File.write("examples/underlined_text_right_align.pdf", &1))
<object width="400" height="130" data="examples/underlined_text_right_align.pdf?#navpanes=0" type="application/pdf"></object>
[Underlined, centre-aligned text](examples/underlined_text_centre_align.pdf?#navpanes=0).
iex> import Mudbrick
...> new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
...> |> page(size: {100, 50})
...> |> text([
...> {"the\\n", leading: 20},
...> "quick\\n",
...> "brown fox ",
...> {"jumps", underline: [width: 1]},
...> " over"
...> ], position: {50, 40}, font_size: 8, align: :centre)
...> |> render()
...> |> then(&File.write("examples/underlined_text_centre_align.pdf", &1))
<object width="400" height="130" data="examples/underlined_text_centre_align.pdf?#navpanes=0" type="application/pdf"></object>
[Text wrapping](examples/text_wrapping.pdf?#navpanes=0).
iex> import Mudbrick
...> import Mudbrick.TestHelper
...> new(fonts: %{bodoni: bodoni_regular()})
...> |> page(size: {250, 350})
...> |> text(
...> "This is a very long line of text that should be wrapped automatically to fit within the specified width constraints. It will break at word boundaries and create multiple lines as needed.",
...> font: :bodoni,
...> font_size: 12,
...> position: {10, 330},
...> max_width: 230
...> )
...> |> render()
...> |> then(&File.write("examples/text_wrapping.pdf", &1))
<object width="400" height="400" data="examples/text_wrapping.pdf?#navpanes=0" type="application/pdf"></object>
[Text wrapping with justification](examples/text_wrapping_justified.pdf?#navpanes=0).
iex> import Mudbrick
...> import Mudbrick.TestHelper
...> new(fonts: %{bodoni: bodoni_regular()})
...> |> page(size: {300, 400})
...> |> text(
...> "This text is fully justified. Spaces are distributed evenly between words to align both left and right margins. The last line is not justified.",
...> font: :bodoni,
...> font_size: 12,
...> position: {10, 380},
...> max_width: 280,
...> justify: :justify
...> )
...> |> render()
...> |> then(&File.write("examples/text_wrapping_justified.pdf", &1))
<object width="400" height="400" data="examples/text_wrapping_justified.pdf?#navpanes=0" type="application/pdf"></object>
"""
@spec text(context(), Mudbrick.TextBlock.write(), Mudbrick.TextBlock.options()) :: context()
def text(context, write_or_writes, opts \\ [])
def text({doc, _contents_obj} = context, writes, opts) when is_list(writes) do
# Check if max_width is set for wrapping
if Keyword.has_key?(opts, :max_width) do
max_width = Keyword.fetch!(opts, :max_width)
wrap_opts = Keyword.take(opts, [:break_words, :hyphenate, :indent, :justify])
text_block_opts = Keyword.drop(opts, [:max_width, :break_words, :hyphenate, :indent, :justify])
ContentStream.update_operations(context, fn ops ->
# Fetch font value from opts
font_opts = fetch_font(doc, text_block_opts)
tb = TextBlock.new(font_opts)
# Apply wrapping to each text element in the list
tb = Enum.reduce(writes, tb, fn
{text, write_opts}, acc_tb ->
merged_wrap_opts = Keyword.merge(wrap_opts, write_opts)
TextBlock.write_wrapped(acc_tb, text, max_width, merged_wrap_opts)
text, acc_tb ->
TextBlock.write_wrapped(acc_tb, text, max_width, wrap_opts)
end)
output = TextBlock.Output.to_iodata(tb)
output.operations ++ ops
end)
else
ContentStream.update_operations(context, fn ops ->
output =
text_block(doc, writes, fetch_font(doc, opts))
|> TextBlock.Output.to_iodata()
output.operations ++ ops
end)
end
end
def text({doc, _contents_obj} = context, write, opts) when is_binary(write) do
# Check if max_width is set for wrapping
if Keyword.has_key?(opts, :max_width) do
max_width = Keyword.fetch!(opts, :max_width)
wrap_opts = Keyword.take(opts, [:break_words, :hyphenate, :indent, :justify])
text_block_opts = Keyword.drop(opts, [:max_width, :break_words, :hyphenate, :indent, :justify])
ContentStream.update_operations(context, fn ops ->
# Fetch font value from opts and merge with text_block_opts
font_opts = fetch_font(doc, text_block_opts)
tb = TextBlock.new(font_opts)
tb = TextBlock.write_wrapped(tb, write, max_width, wrap_opts)
output = TextBlock.Output.to_iodata(tb)
output.operations ++ ops
end)
else
text(context, [write], opts)
end
end
def text(context, write, opts) do
text(context, [write], opts)
end
@doc """
Vector drawing. *f* is a function that takes a `Mudbrick.Path` and
returns a `Mudbrick.Path`. See the functions in that module.
## Example
A thick diagonal red line and a black rectangle with a thinner (default)
line on top.
iex> import Mudbrick
...> new()
...> |> page(size: {100, 100})
...> |> path(fn path ->
...> import Mudbrick.Path
...> path
...> |> move(to: {0, 0})
...> |> line(to: {50, 50}, colour: {1, 0, 0}, width: 9)
...> |> rectangle(lower_left: {0, 0}, dimensions: {50, 60})
...> end)
...> |> render()
...> |> then(&File.write("examples/drawing.pdf", &1))
Produces [this drawing](examples/drawing.pdf).
<object width="400" height="215" data="examples/drawing.pdf?#navpanes=0" type="application/pdf"></object>
"""
@spec path(context(), (Path.t() -> Path.t())) :: context()
def path(context, f) do
path = f.(Path.new())
context
|> ContentStream.update_operations(fn ops ->
Path.Output.to_iodata(path).operations ++ ops
end)
end
@doc """
Produce `iodata` from the current document.
"""
@spec render(Document.t() | context()) :: iodata()
def render({doc, _page}) do
render(doc)
end
def render(doc) do
Mudbrick.Object.to_iodata(doc)
end
@doc false
def to_hex(n) do
n
|> Integer.to_string(16)
|> String.pad_leading(4, "0")
end
@doc false
def join(a, separator \\ " ")
def join(tuple, separator) when is_tuple(tuple) do
tuple
|> Tuple.to_list()
|> join(separator)
end
def join(list, separator) do
Enum.map_join(list, separator, &Mudbrick.Object.to_iodata/1)
end
@doc """
Compress data with the same method that PDF generation does. Useful for testing.
## Example
iex> Mudbrick.compress(["hi", "there", ["you"]])
[<<120, 156, 203, 200, 44, 201, 72, 45, 74, 173, 204, 47, 5, 0, 23, 45, 4, 71>>]
"""
@spec compress(iodata()) :: iodata()
def compress(data) do
z = :zlib.open()
:ok = :zlib.deflateInit(z)
deflated = :zlib.deflate(z, data, :finish)
:zlib.deflateEnd(z)
:zlib.close(z)
deflated
end
@doc """
Decompress data with the same method that PDF generation does. Useful for testing.
## Example
iex> Mudbrick.decompress([<<120, 156, 203, 200, 44, 201, 72, 45, 74, 173, 204, 47, 5, 0, 23, 45, 4, 71>>])
["hithereyou"]
"""
@spec decompress(iodata()) :: iodata()
def decompress(data) do
z = :zlib.open()
:zlib.inflateInit(z)
inflated = :zlib.inflate(z, data)
:zlib.inflateEnd(z)
:zlib.close(z)
inflated
end
defp contents({doc, page}) do
import Document
doc
|> add(ContentStream.new(compress: doc.compress, page: page.value))
|> update(page, fn contents, %Page{} = p ->
%{p | contents: contents}
end)
|> finish(& &1.value.contents)
end
defp text_block(doc, writes, top_level_opts) do
Enum.reduce(writes, Mudbrick.TextBlock.new(top_level_opts), fn
{text, opts}, acc ->
Mudbrick.TextBlock.write(acc, text, fetch_font(doc, opts))
text, acc ->
Mudbrick.TextBlock.write(acc, text, fetch_font(doc, []))
end)
end
@spec cm_opts(Mudbrick.Image.t(), Image.image_options()) :: Mudbrick.ContentStream.Cm.options()
defp cm_opts(image, image_opts) do
scale =
case image_opts[:scale] do
{:auto, :auto} ->
raise Mudbrick.Image.AutoScalingError,
"Auto scaling works with width or height, but not both."
{w, :auto} ->
scaled_height(w, image)
{:auto, h} ->
scaled_width(h, image)
nil ->
scaled_height(100, image)
otherwise ->
otherwise
end
Keyword.put(image_opts, :scale, scale)
end
defp scaled_height(w, image) do
ratio = w / image.width
{w, image.height * ratio}
end
defp scaled_width(h, image) do
ratio = h / image.height
{image.width * ratio, h}
end
defp fetch_font(doc, opts) do
default_font =
case Map.values(Document.root_page_tree(doc).value.fonts) do
[font] -> font.value
_ -> nil
end
Keyword.update(opts, :font, default_font, fn user_identifier ->
case Map.fetch(Document.root_page_tree(doc).value.fonts, user_identifier) do
{:ok, font} ->
font.value
:error ->
raise Font.Unregistered, "Unregistered font: #{user_identifier}"
end
end)
end
end