├── .credo.exs
├── .envrc
├── .formatter.exs
├── .github
└── workflows
│ └── pipeline.yml
├── .gitignore
├── LICENSE
├── README.md
├── config
└── config.exs
├── examples
└── .gitkeep
├── flake.lock
├── flake.nix
├── lib
├── mudbrick.ex
└── mudbrick
│ ├── catalog.ex
│ ├── content_stream.ex
│ ├── content_stream
│ ├── bt_et.ex
│ ├── cm.ex
│ ├── do.ex
│ ├── l.ex
│ ├── m.ex
│ ├── q.ex
│ ├── re.ex
│ ├── rg.ex
│ ├── s.ex
│ ├── t_star.ex
│ ├── td.ex
│ ├── tf.ex
│ ├── tj.ex
│ ├── tl.ex
│ └── w.ex
│ ├── document.ex
│ ├── font.ex
│ ├── font
│ ├── cid_font.ex
│ ├── cmap.ex
│ └── descriptor.ex
│ ├── image.ex
│ ├── indirect.ex
│ ├── metadata.ex
│ ├── page.ex
│ ├── page_tree.ex
│ ├── parser.ex
│ ├── parser
│ ├── ast.ex
│ └── helpers.ex
│ ├── path.ex
│ ├── path
│ └── output.ex
│ ├── predicates.ex
│ ├── serialisation
│ ├── document.ex
│ └── object.ex
│ ├── stream.ex
│ ├── text_block.ex
│ └── text_block
│ ├── line.ex
│ └── output.ex
├── mix.exs
├── mix.lock
└── test
├── drawing_test.exs
├── fixtures
├── Example.png
└── JPEG_example_flower.jpg
├── font_test.exs
├── image_test.exs
├── metadata_test.exs
├── mudbrick
├── catalog_test.exs
├── indirect_test.exs
├── page_test.exs
├── page_tree_test.exs
├── parser
│ ├── roundtrip_test.exs
│ └── text_content_test.exs
├── parser_test.exs
├── serialisation
│ └── object_test.exs
├── stream_test.exs
└── text_block_test.exs
├── mudbrick_test.exs
├── predicates_test.exs
├── test_helper.exs
└── text_test.exs
/.credo.exs:
--------------------------------------------------------------------------------
1 | # This file contains the configuration for Credo and you are probably reading
2 | # this after creating it with `mix credo.gen.config`.
3 | #
4 | # If you find anything wrong or unclear in this file, please report an
5 | # issue on GitHub: https://github.com/rrrene/credo/issues
6 | #
7 | %{
8 | #
9 | # You can have as many configs as you like in the `configs:` field.
10 | configs: [
11 | %{
12 | #
13 | # Run any config using `mix credo -C `. If no config name is given
14 | # "default" is used.
15 | #
16 | name: "default",
17 | #
18 | # These are the files included in the analysis:
19 | files: %{
20 | #
21 | # You can give explicit globs or simply directories.
22 | # In the latter case `**/*.{ex,exs}` will be used.
23 | #
24 | included: [
25 | "lib/",
26 | "src/",
27 | "test/",
28 | "web/",
29 | "apps/*/lib/",
30 | "apps/*/src/",
31 | "apps/*/test/",
32 | "apps/*/web/"
33 | ],
34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
35 | },
36 | #
37 | # Load and configure plugins here:
38 | #
39 | plugins: [],
40 | #
41 | # If you create your own checks, you must specify the source files for
42 | # them here, so they can be loaded by Credo before running the analysis.
43 | #
44 | requires: [],
45 | #
46 | # If you want to enforce a style guide and need a more traditional linting
47 | # experience, you can change `strict` to `true` below:
48 | #
49 | strict: true,
50 | #
51 | # To modify the timeout for parsing files, change this value:
52 | #
53 | parse_timeout: 5000,
54 | #
55 | # If you want to use uncolored output by default, you can change `color`
56 | # to `false` below:
57 | #
58 | color: true,
59 | #
60 | # You can customize the parameters of any check by adding a second element
61 | # to the tuple.
62 | #
63 | # To disable a check put `false` as second element:
64 | #
65 | # {Credo.Check.Design.DuplicatedCode, false}
66 | #
67 | checks: %{
68 | enabled: [
69 | #
70 | ## Consistency Checks
71 | #
72 | # {Credo.Check.Consistency.ExceptionNames, []},
73 | {Credo.Check.Consistency.LineEndings, []},
74 | {Credo.Check.Consistency.ParameterPatternMatching, []},
75 | {Credo.Check.Consistency.SpaceAroundOperators, []},
76 | {Credo.Check.Consistency.SpaceInParentheses, []},
77 | {Credo.Check.Consistency.TabsOrSpaces, []},
78 |
79 | #
80 | ## Design Checks
81 | #
82 | # You can customize the priority of any check
83 | # Priority values are: `low, normal, high, higher`
84 | #
85 | {Credo.Check.Design.AliasUsage,
86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
87 | {Credo.Check.Design.TagFIXME, []},
88 | # You can also customize the exit_status of each check.
89 | # If you don't want TODO comments to cause `mix credo` to fail, just
90 | # set this value to 0 (zero).
91 | #
92 | {Credo.Check.Design.TagTODO, [exit_status: 2]},
93 |
94 | #
95 | ## Readability Checks
96 | #
97 | {Credo.Check.Readability.AliasOrder, []},
98 | {Credo.Check.Readability.FunctionNames, []},
99 | {Credo.Check.Readability.LargeNumbers, []},
100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
101 | {Credo.Check.Readability.ModuleAttributeNames, []},
102 | {Credo.Check.Readability.ModuleNames, []},
103 | {Credo.Check.Readability.ParenthesesInCondition, []},
104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
105 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
106 | {Credo.Check.Readability.PredicateFunctionNames, []},
107 | {Credo.Check.Readability.PreferImplicitTry, []},
108 | {Credo.Check.Readability.RedundantBlankLines, []},
109 | {Credo.Check.Readability.Semicolons, []},
110 | {Credo.Check.Readability.SpaceAfterCommas, []},
111 | {Credo.Check.Readability.StringSigils, []},
112 | {Credo.Check.Readability.TrailingBlankLine, []},
113 | {Credo.Check.Readability.TrailingWhiteSpace, []},
114 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []},
115 | {Credo.Check.Readability.VariableNames, []},
116 | {Credo.Check.Readability.WithSingleClause, []},
117 |
118 | #
119 | ## Refactoring Opportunities
120 | #
121 | {Credo.Check.Refactor.Apply, []},
122 | {Credo.Check.Refactor.CondStatements, []},
123 | {Credo.Check.Refactor.CyclomaticComplexity, []},
124 | {Credo.Check.Refactor.FilterCount, []},
125 | {Credo.Check.Refactor.FilterFilter, []},
126 | {Credo.Check.Refactor.FunctionArity, []},
127 | {Credo.Check.Refactor.LongQuoteBlocks, []},
128 | {Credo.Check.Refactor.MapJoin, []},
129 | {Credo.Check.Refactor.MatchInCondition, []},
130 | {Credo.Check.Refactor.NegatedConditionsInUnless, []},
131 | {Credo.Check.Refactor.NegatedConditionsWithElse, []},
132 | {Credo.Check.Refactor.Nesting, []},
133 | {Credo.Check.Refactor.RedundantWithClauseResult, []},
134 | {Credo.Check.Refactor.RejectReject, []},
135 | {Credo.Check.Refactor.UnlessWithElse, []},
136 | {Credo.Check.Refactor.WithClauses, []},
137 |
138 | #
139 | ## Warnings
140 | #
141 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
142 | {Credo.Check.Warning.BoolOperationOnSameValues, []},
143 | {Credo.Check.Warning.Dbg, []},
144 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
145 | {Credo.Check.Warning.IExPry, []},
146 | {Credo.Check.Warning.IoInspect, []},
147 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
148 | {Credo.Check.Warning.OperationOnSameValues, []},
149 | {Credo.Check.Warning.OperationWithConstantResult, []},
150 | {Credo.Check.Warning.RaiseInsideRescue, []},
151 | {Credo.Check.Warning.SpecWithStruct, []},
152 | {Credo.Check.Warning.UnsafeExec, []},
153 | {Credo.Check.Warning.UnusedEnumOperation, []},
154 | {Credo.Check.Warning.UnusedFileOperation, []},
155 | {Credo.Check.Warning.UnusedKeywordOperation, []},
156 | {Credo.Check.Warning.UnusedListOperation, []},
157 | {Credo.Check.Warning.UnusedPathOperation, []},
158 | {Credo.Check.Warning.UnusedRegexOperation, []},
159 | {Credo.Check.Warning.UnusedStringOperation, []},
160 | {Credo.Check.Warning.UnusedTupleOperation, []},
161 | {Credo.Check.Warning.WrongTestFileExtension, []}
162 | ],
163 | disabled: [
164 | #
165 | # Checks scheduled for next check update (opt-in for now)
166 | {Credo.Check.Refactor.UtcNowTruncate, []},
167 |
168 | #
169 | # Controversial and experimental checks (opt-in, just move the check to `:enabled`
170 | # and be sure to use `mix credo --strict` to see low priority checks)
171 | #
172 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []},
173 | {Credo.Check.Consistency.UnusedVariableNames, []},
174 | {Credo.Check.Design.DuplicatedCode, []},
175 | {Credo.Check.Design.SkipTestWithoutComment, []},
176 | {Credo.Check.Readability.AliasAs, []},
177 | {Credo.Check.Readability.BlockPipe, []},
178 | {Credo.Check.Readability.ImplTrue, []},
179 | {Credo.Check.Readability.MultiAlias, []},
180 | {Credo.Check.Readability.NestedFunctionCalls, []},
181 | {Credo.Check.Readability.OneArityFunctionInPipe, []},
182 | {Credo.Check.Readability.OnePipePerLine, []},
183 | {Credo.Check.Readability.SeparateAliasRequire, []},
184 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []},
185 | {Credo.Check.Readability.SinglePipe, []},
186 | {Credo.Check.Readability.Specs, []},
187 | {Credo.Check.Readability.StrictModuleLayout, []},
188 | {Credo.Check.Readability.WithCustomTaggedTuple, []},
189 | {Credo.Check.Refactor.ABCSize, []},
190 | {Credo.Check.Refactor.AppendSingleItem, []},
191 | {Credo.Check.Refactor.DoubleBooleanNegation, []},
192 | {Credo.Check.Refactor.FilterReject, []},
193 | {Credo.Check.Refactor.IoPuts, []},
194 | {Credo.Check.Refactor.MapMap, []},
195 | {Credo.Check.Refactor.ModuleDependencies, []},
196 | {Credo.Check.Refactor.NegatedIsNil, []},
197 | {Credo.Check.Refactor.PassAsyncInTestCases, []},
198 | {Credo.Check.Refactor.PipeChainStart, []},
199 | {Credo.Check.Refactor.RejectFilter, []},
200 | {Credo.Check.Refactor.VariableRebinding, []},
201 | {Credo.Check.Warning.LazyLogging, []},
202 | {Credo.Check.Warning.LeakyEnvironment, []},
203 | {Credo.Check.Warning.MapGetUnsafePass, []},
204 | {Credo.Check.Warning.MixEnv, []},
205 | {Credo.Check.Warning.UnsafeToAtom, []}
206 |
207 | # {Credo.Check.Refactor.MapInto, []},
208 |
209 | #
210 | # Custom checks can be created using `mix credo.gen.check`.
211 | #
212 | ]
213 | }
214 | }
215 | ]
216 | }
217 |
--------------------------------------------------------------------------------
/.envrc:
--------------------------------------------------------------------------------
1 | use flake
2 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | import_deps: [:stream_data],
4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
5 | ]
6 |
--------------------------------------------------------------------------------
/.github/workflows/pipeline.yml:
--------------------------------------------------------------------------------
1 | name: Pipeline
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | workflow_dispatch:
7 | schedule: {cron: "0 0 * * *"}
8 | pull_request:
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-24.04
13 | defaults:
14 | run:
15 | shell: nix develop . --command -- bash -e {0}
16 | steps:
17 | - uses: actions/checkout@v4
18 | - uses: actions/cache@v4
19 | with:
20 | key: ${{ runner.os }}-dev-build-v1-${{ hashFiles('mix.lock', 'flake.lock') }}
21 | path: |
22 | deps
23 | _build
24 | - uses: DeterminateSystems/nix-installer-action@main
25 | - run: |
26 | mix do deps.get
27 | mix dialyzer
28 | mix credo
29 | mix test
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | mudbrick-*.tar
24 |
25 | # Temporary files, for example, from tests.
26 | /tmp/
27 | /.direnv
28 | test.pdf
29 | /examples/*.pdf
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Andrew Bruce
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mudbrick
2 |
3 | [API Documentation](https://hexdocs.pm/mudbrick/Mudbrick.html)
4 |
5 | Early-stages PDF generator, beelining for:
6 |
7 | - PDF 2.0 support.
8 | - In-process, pure functional approach.
9 | - OpenType support.
10 | - Special characters and ligatures, like ₛᵤ₆ₛ꜀ᵣᵢₚₜₛ for chemical compounds etc.
11 |
12 | Currently working:
13 |
14 | - OpenType fonts with ligatures, special characters and automatic kerning.
15 | - Text positioning.
16 | - Right and centre alignment.
17 | - Coloured text.
18 | - JPEG images.
19 | - Compression.
20 | - Underline with colour and thickness options.
21 | - Basic line drawing.
22 |
23 | To do:
24 |
25 | - Other image formats.
26 | - Font subsetting.
27 | - Vector graphics.
28 | - Strikethrough.
29 | - Text highlight.
30 |
31 | ## Installation
32 |
33 | ```elixir
34 | def deps do
35 | [
36 | {:mudbrick, "~> 0.0"}
37 | ]
38 | end
39 | ```
40 |
41 | ## What's the deal with the name?
42 |
43 | It's a play on Adobe, which means mudbrick in Spanish.
44 |
45 | ## See also
46 |
47 | - [elixir-pdf](https://github.com/andrewtimberlake/elixir-pdf), a more mature
48 | library, supporting AFM instead of OTF fonts, but only the base
49 | WinAnsiEncoding and no special characters. Uses a GenServer for state.
50 | - [erlguten](https://github.com/hwatkins/erlguten), an antiquated Erlang
51 | PDF generator.
52 | - [opentype-elixir](https://github.com/jbowtie/opentype-elixir), used for OTF
53 | parsing.
54 | - [ex_image_info](https://github.com/Group4Layers/ex_image_info), used for
55 | image metadata parsing.
56 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | config :logger, level: :info
4 |
--------------------------------------------------------------------------------
/examples/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-supply/mudbrick/521d4d546f6b90ea49a4908c26e284c654696ec8/examples/.gitkeep
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "nixpkgs": {
4 | "locked": {
5 | "lastModified": 1735915915,
6 | "narHash": "sha256-Q4HuFAvoKAIiTRZTUxJ0ZXeTC7lLfC9/dggGHNXNlCw=",
7 | "owner": "nixos",
8 | "repo": "nixpkgs",
9 | "rev": "a27871180d30ebee8aa6b11bf7fef8a52f024733",
10 | "type": "github"
11 | },
12 | "original": {
13 | "owner": "nixos",
14 | "ref": "nixpkgs-unstable",
15 | "repo": "nixpkgs",
16 | "type": "github"
17 | }
18 | },
19 | "root": {
20 | "inputs": {
21 | "nixpkgs": "nixpkgs"
22 | }
23 | }
24 | },
25 | "root": "root",
26 | "version": 7
27 | }
28 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | inputs = {
3 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
4 | };
5 |
6 | outputs =
7 | { self, nixpkgs }:
8 | let
9 | forAllSystems =
10 | function:
11 | nixpkgs.lib.genAttrs [
12 | "x86_64-linux"
13 | "aarch64-darwin"
14 | ] (system: function nixpkgs.legacyPackages.${system});
15 | in
16 | {
17 | devShells = forAllSystems (pkgs: {
18 | default =
19 | with pkgs;
20 | mkShell {
21 | packages =
22 | let
23 | release = writeShellApplication {
24 | name = "release";
25 | runtimeInputs = [
26 | elixir
27 | gh
28 | ];
29 | text = ''
30 | tag=$1
31 |
32 | mix test
33 | gh repo set-default code-supply/mudbrick
34 | gh release create "$tag" --draft --generate-notes
35 | mix hex.publish
36 | '';
37 | };
38 | in
39 | [
40 | elixir
41 | qpdf
42 | release
43 | ]
44 | ++ lib.lists.optional stdenv.isLinux inotify-tools
45 | ++ lib.lists.optional stdenv.isDarwin darwin.apple_sdk.frameworks.CoreServices;
46 |
47 | shellHook = ''
48 | export ERL_AFLAGS="-kernel shell_history enabled shell_history_file_bytes 1024000"
49 | export FONT_LIBRE_BODONI_REGULAR="${libre-bodoni}/share/fonts/opentype/LibreBodoni-Regular.otf"
50 | export FONT_LIBRE_BODONI_BOLD="${libre-bodoni}/share/fonts/opentype/LibreBodoni-Bold.otf"
51 | export FONT_LIBRE_FRANKLIN_REGULAR="${libre-franklin}/share/fonts/opentype/LibreFranklin-Regular.otf"
52 | '';
53 | };
54 | });
55 | };
56 | }
57 |
--------------------------------------------------------------------------------
/lib/mudbrick.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick do
2 | @moduledoc """
3 | API for creating and exporting PDF documents.
4 |
5 | ## General example
6 |
7 | Compression, OTF font with special characters, JPEG and line drawing:
8 |
9 | iex> import Mudbrick.TestHelper # import some example fonts and images
10 | ...> import Mudbrick
11 | ...> new(
12 | ...> compress: true, # flate compression for fonts, text etc.
13 | ...> fonts: %{bodoni: bodoni_regular()}, # register an OTF font
14 | ...> images: %{flower: flower()} # register a JPEG
15 | ...> )
16 | ...> |> page(size: {100, 100})
17 | ...> |> image( # place preregistered JPEG
18 | ...> :flower,
19 | ...> scale: {100, 100}, # full page size
20 | ...> position: {0, 0} # in points (1/72 inch), starts at bottom left
21 | ...> )
22 | ...> |> path(fn path -> # draw a line
23 | ...> import Mudbrick.Path
24 | ...> path
25 | ...> |> move(to: {55, 40}) # starting near the middle of the page
26 | ...> |> line(
27 | ...> to: {95, 5}, # ending near the bottom right
28 | ...> width: 6.0, # make it fat
29 | ...> colour: {1, 0, 0} # make it red
30 | ...> )
31 | ...> end)
32 | ...> |> text(
33 | ...> {"CO₂", colour: {0, 0, 1}}, # write blue text
34 | ...> font: :bodoni, # in the bodoni font
35 | ...> font_size: 14, # size 14 points
36 | ...> position: {35, 45} # 60 points from left, 45 from bottom of page
37 | ...> )
38 | ...> |> render() # produce iodata, ready for File.write/2
39 | ...> |> then(&File.write("examples/compression_font_special_chars.pdf", &1))
40 |
41 | Produces [this](examples/compression_font_special_chars.pdf).
42 |
43 |
44 |
45 | ## Auto-kerning
46 |
47 | iex> import Mudbrick.TestHelper
48 | ...> import Mudbrick
49 | ...> new(fonts: %{bodoni: bodoni_bold()})
50 | ...> |> page(size: {400, 100})
51 | ...> |> text(
52 | ...> [{"Warning\\n", underline: [width: 0.5]}],
53 | ...> font: :bodoni,
54 | ...> font_size: 70,
55 | ...> position: {7, 30}
56 | ...> )
57 | ...> |> render()
58 | ...> |> then(&File.write("examples/auto_kerning.pdf", &1))
59 |
60 | Produces [this](examples/auto_kerning.pdf). Notice how the 'a' is underneath the 'W' in 'Warning'.
61 |
62 |
63 |
64 | It's on by default, but we can turn it off:
65 |
66 | iex> import Mudbrick.TestHelper
67 | ...> import Mudbrick
68 | ...> new(fonts: %{bodoni: bodoni_bold()})
69 | ...> |> page(size: {400, 100})
70 | ...> |> text(
71 | ...> [{"Warning\\n", underline: [width: 0.5]}],
72 | ...> font: :bodoni,
73 | ...> font_size: 70,
74 | ...> position: {7, 30},
75 | ...> auto_kern: false
76 | ...> )
77 | ...> |> render()
78 | ...> |> then(&File.write("examples/auto_kerning_disabled.pdf", &1))
79 |
80 | Produces [this](examples/auto_kerning_disabled.pdf).
81 |
82 |
83 |
84 | """
85 |
86 | alias Mudbrick.{
87 | ContentStream,
88 | Document,
89 | Font,
90 | Image,
91 | Indirect,
92 | Page,
93 | Path,
94 | TextBlock
95 | }
96 |
97 | @type context :: {Document.t(), Indirect.Object.t()}
98 | @type coords :: {number(), number()}
99 | @type colour :: {number(), number(), number()}
100 |
101 | @doc """
102 | Start a new document.
103 |
104 | ## Options
105 |
106 | - `:compress` - when set to `true`, apply deflate compression to streams (if
107 | compression saves space). Default: `false`
108 | - `:fonts` - register OTF or built-in fonts for later use.
109 | - `:images` - register images for later use.
110 |
111 | The following options define metadata for the document:
112 |
113 | - `:producer` - software used to create the document, default: `"Mudbrick"`
114 | - `:creator_tool` - tool used to create the document, default: `"Mudbrick"`
115 | - `:create_date` - `DateTime` representing the document's creation time
116 | - `:modify_date` - `DateTime` representing the document's last update time
117 | - `:title` - title (can change e.g. browser window title), default: `nil`
118 | - `:creators` - list of names of the creators of the document, default: `[]`
119 |
120 | ## Examples
121 |
122 | Register an OTF font. Pass the file's raw data.
123 |
124 | iex> Mudbrick.new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
125 |
126 | Register an image.
127 |
128 | iex> Mudbrick.new(images: %{flower: Mudbrick.TestHelper.flower()})
129 |
130 | Set document metadata.
131 |
132 | iex> Mudbrick.new(title: "The best PDF", producer: "My cool software")
133 | """
134 |
135 | @spec new(opts :: Document.options()) :: Document.t()
136 | def new(opts \\ []) do
137 | Document.new(opts)
138 | end
139 |
140 | @doc """
141 | Start a new page upon which future operators should apply.
142 |
143 | ## Options
144 |
145 | - `:size` - a tuple of `{width, height}`. Some standard sizes available in `Mudbrick.Page.size/1`.
146 | """
147 | @spec page(Document.t() | context(), Keyword.t()) :: context()
148 | def page(context, opts \\ [])
149 |
150 | def page({doc, _contents_obj}, opts) do
151 | page(doc, opts)
152 | end
153 |
154 | def page(doc, opts) do
155 | Page.add(
156 | doc,
157 | Keyword.put_new(
158 | opts,
159 | :size,
160 | Page.size(:a4)
161 | )
162 | )
163 | |> contents()
164 | end
165 |
166 | @doc """
167 | Insert image previously registered in `new/1` at the given coordinates.
168 |
169 | ## Options
170 |
171 | - `:position` - `{x, y}` in points, relative to bottom-left corner.
172 | - `:scale` - `{w, h}` in points. To preserve aspect ratio, set either, but not both, to `:auto`.
173 | - `:skew` - `{x, y}`, passed through to PDF `cm` operator.
174 |
175 | All options default to `{0, 0}`.
176 |
177 | ## Examples
178 |
179 | iex> Mudbrick.new(images: %{lovely_flower: Mudbrick.TestHelper.flower()})
180 | ...> |> Mudbrick.page()
181 | ...> |> Mudbrick.image(:lovely_flower, position: {100, 100}, scale: {100, 100})
182 |
183 | Forgetting to register the image:
184 |
185 | iex> Mudbrick.new()
186 | ...> |> Mudbrick.page()
187 | ...> |> Mudbrick.image(:my_face, position: {100, 100}, scale: {100, 100})
188 | ** (Mudbrick.Image.Unregistered) Unregistered image: my_face
189 |
190 | Auto height:
191 |
192 | iex> Mudbrick.new(images: %{lovely_flower: Mudbrick.TestHelper.flower()})
193 | ...> |> Mudbrick.page(size: {50, 50})
194 | ...> |> Mudbrick.image(:lovely_flower, position: {0, 0}, scale: {50, :auto})
195 | ...> |> Mudbrick.render()
196 | ...> |> then(&File.write("examples/image_auto_aspect_scale.pdf", &1))
197 |
198 |
199 |
200 | Attempting to set both width and height to `:auto`:
201 |
202 | iex> Mudbrick.new(images: %{lovely_flower: Mudbrick.TestHelper.flower()})
203 | ...> |> Mudbrick.page()
204 | ...> |> Mudbrick.image(:lovely_flower, position: {100, 100}, scale: {:auto, :auto})
205 | ** (Mudbrick.Image.AutoScalingError) Auto scaling works with width or height, but not both.
206 |
207 | Tip: to make the image fit the page, pass e.g. `Page.size(:a4)` as the
208 | `scale` and `{0, 0}` as the `position`.
209 | """
210 |
211 | @spec image(context(), atom(), Image.image_options()) :: context()
212 | def image({doc, _content_stream_obj} = context, user_identifier, opts \\ []) do
213 | import ContentStream
214 |
215 | case Map.fetch(Document.root_page_tree(doc).value.images, user_identifier) do
216 | {:ok, image} ->
217 | context
218 | |> add(%ContentStream.QPush{})
219 | |> add(ContentStream.Cm.new(cm_opts(image.value, opts)))
220 | |> add(%ContentStream.Do{image_identifier: image.value.resource_identifier})
221 | |> add(%ContentStream.QPop{})
222 |
223 | :error ->
224 | raise Image.Unregistered, "Unregistered image: #{user_identifier}"
225 | end
226 | end
227 |
228 | @doc """
229 | Write text at the given coordinates.
230 |
231 | ## Top-level options
232 |
233 | - `:colour` - `{r, g, b}` tuple. Each element is a number between 0 and 1. Default: `{0, 0, 0}`.
234 | - `:font` - Name of a font previously registered with `new/1`. Required unless you've only registered one font.
235 | - `:position` - Coordinates from bottom-left of page in points. Default: `{0, 0}`.
236 | - `:font_size` - Size in points. Default: `12`.
237 | - `:leading` - Leading in points. Default is 120% of `:font_size`.
238 | - `:align` - `:left`, `:right` or `:centre`. Default: `:left`.
239 | Note that the rightmost point of right-aligned text is the horizontal offset provided to `:position`.
240 | The same position defines the centre point of centre-aligned text.
241 |
242 | ## Individual write options
243 |
244 | When passing a `{text, opts}` tuple or list of tuples to this function, `opts` are:
245 |
246 | - `:colour` - `{r, g, b}` tuple. Each element is a number between 0 and 1. Overrides the top-level option.
247 | - `:font` - Name of a font previously registered with `new/1`. Overrides the top-level option.
248 | - `:font_size` - Size in points. Overrides the top-level option.
249 | - `:leading` - The number of points to move down the page on the following linebreak. Overrides the top-level option.
250 | - `:underline` - A list of options: `:width` in points, `:colour` as an `{r, g, b}` struct.
251 |
252 | ## Examples
253 |
254 | Write "CO₂" in the bottom-left corner of a default-sized page.
255 |
256 | iex> import Mudbrick.TestHelper
257 | ...> import Mudbrick
258 | ...> new(fonts: %{bodoni: bodoni_regular()})
259 | ...> |> page()
260 | ...> |> text("CO₂")
261 |
262 | Write "I am red" at 200, 200, where "red" is in red.
263 |
264 | iex> import Mudbrick.TestHelper
265 | ...> import Mudbrick
266 | ...> new(fonts: %{bodoni: bodoni_regular()})
267 | ...> |> page()
268 | ...> |> text(["I am ", {"red", colour: {1, 0, 0}}], position: {200, 200})
269 |
270 | Write "I am bold" at 200, 200, where "bold" is in bold.
271 |
272 | iex> import Mudbrick.TestHelper
273 | ...> import Mudbrick
274 | ...> new(fonts: %{regular: bodoni_regular(), bold: bodoni_bold()})
275 | ...> |> page()
276 | ...> |> text(["I am ", {"bold", font: :bold}], font: :regular, position: {200, 200})
277 |
278 | [Underlined text](examples/underlined_text.pdf?#navpanes=0).
279 |
280 | iex> import Mudbrick
281 | ...> new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
282 | ...> |> page(size: {100, 50})
283 | ...> |> text([
284 | ...> {"the\\n", leading: 20},
285 | ...> "quick\\n",
286 | ...> "brown fox ",
287 | ...> {"jumps", underline: [width: 1]},
288 | ...> " over"
289 | ...> ], position: {8, 40}, font_size: 8)
290 | ...> |> render()
291 | ...> |> then(&File.write("examples/underlined_text.pdf", &1))
292 |
293 |
294 |
295 | [Underlined, right-aligned text](examples/underlined_text_right_align.pdf?#navpanes=0).
296 |
297 | iex> import Mudbrick
298 | ...> new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
299 | ...> |> page(size: {100, 50})
300 | ...> |> text([
301 | ...> {"the\\n", leading: 20},
302 | ...> "quick\\n",
303 | ...> "brown fox ",
304 | ...> {"jumps", underline: [width: 1]},
305 | ...> " over"
306 | ...> ], position: {90, 40}, font_size: 8, align: :right)
307 | ...> |> render()
308 | ...> |> then(&File.write("examples/underlined_text_right_align.pdf", &1))
309 |
310 |
311 |
312 | [Underlined, centre-aligned text](examples/underlined_text_centre_align.pdf?#navpanes=0).
313 |
314 | iex> import Mudbrick
315 | ...> new(fonts: %{bodoni: Mudbrick.TestHelper.bodoni_regular()})
316 | ...> |> page(size: {100, 50})
317 | ...> |> text([
318 | ...> {"the\\n", leading: 20},
319 | ...> "quick\\n",
320 | ...> "brown fox ",
321 | ...> {"jumps", underline: [width: 1]},
322 | ...> " over"
323 | ...> ], position: {50, 40}, font_size: 8, align: :centre)
324 | ...> |> render()
325 | ...> |> then(&File.write("examples/underlined_text_centre_align.pdf", &1))
326 |
327 |
328 | """
329 |
330 | @spec text(context(), Mudbrick.TextBlock.write(), Mudbrick.TextBlock.options()) :: context()
331 | def text(context, write_or_writes, opts \\ [])
332 |
333 | def text({doc, _contents_obj} = context, writes, opts) when is_list(writes) do
334 | ContentStream.update_operations(context, fn ops ->
335 | output =
336 | doc
337 | |> text_block(writes, fetch_font(doc, opts))
338 | |> TextBlock.Output.to_iodata()
339 |
340 | output.operations ++ ops
341 | end)
342 | end
343 |
344 | def text(context, write, opts) do
345 | text(context, [write], opts)
346 | end
347 |
348 | @doc """
349 | Vector drawing. *f* is a function that takes a `Mudbrick.Path` and
350 | returns a `Mudbrick.Path`. See the functions in that module.
351 |
352 | ## Example
353 |
354 | A thick diagonal red line and a black rectangle with a thinner (default)
355 | line on top.
356 |
357 | iex> import Mudbrick
358 | ...> new()
359 | ...> |> page(size: {100, 100})
360 | ...> |> path(fn path ->
361 | ...> import Mudbrick.Path
362 | ...> path
363 | ...> |> move(to: {0, 0})
364 | ...> |> line(to: {50, 50}, colour: {1, 0, 0}, width: 9)
365 | ...> |> rectangle(lower_left: {0, 0}, dimensions: {50, 60})
366 | ...> end)
367 | ...> |> render()
368 | ...> |> then(&File.write("examples/drawing.pdf", &1))
369 |
370 | Produces [this drawing](examples/drawing.pdf).
371 |
372 |
373 | """
374 |
375 | @spec path(context(), (Path.t() -> Path.t())) :: context()
376 | def path(context, f) do
377 | path = f.(Path.new())
378 |
379 | context
380 | |> ContentStream.update_operations(fn ops ->
381 | Path.Output.to_iodata(path).operations ++ ops
382 | end)
383 | end
384 |
385 | @doc """
386 | Produce `iodata` from the current document.
387 | """
388 | @spec render(Document.t() | context()) :: iodata()
389 | def render({doc, _page}) do
390 | render(doc)
391 | end
392 |
393 | def render(doc) do
394 | Mudbrick.Object.to_iodata(doc)
395 | end
396 |
397 | @doc false
398 | def to_hex(n) do
399 | n
400 | |> Integer.to_string(16)
401 | |> String.pad_leading(4, "0")
402 | end
403 |
404 | @doc false
405 | def join(a, separator \\ " ")
406 |
407 | def join(tuple, separator) when is_tuple(tuple) do
408 | tuple
409 | |> Tuple.to_list()
410 | |> join(separator)
411 | end
412 |
413 | def join(list, separator) do
414 | Enum.map_join(list, separator, &Mudbrick.Object.to_iodata/1)
415 | end
416 |
417 | @doc """
418 | Compress data with the same method that PDF generation does. Useful for testing.
419 |
420 | ## Example
421 |
422 | iex> Mudbrick.compress(["hi", "there", ["you"]])
423 | [<<120, 156, 203, 200, 44, 201, 72, 45, 74, 173, 204, 47, 5, 0, 23, 45, 4, 71>>]
424 | """
425 | @spec compress(iodata()) :: iodata()
426 | def compress(data) do
427 | z = :zlib.open()
428 | :ok = :zlib.deflateInit(z)
429 | deflated = :zlib.deflate(z, data, :finish)
430 | :zlib.deflateEnd(z)
431 | :zlib.close(z)
432 | deflated
433 | end
434 |
435 | @doc """
436 | Decompress data with the same method that PDF generation does. Useful for testing.
437 |
438 | ## Example
439 |
440 | iex> Mudbrick.decompress([<<120, 156, 203, 200, 44, 201, 72, 45, 74, 173, 204, 47, 5, 0, 23, 45, 4, 71>>])
441 | ["hithereyou"]
442 | """
443 | @spec decompress(iodata()) :: iodata()
444 | def decompress(data) do
445 | z = :zlib.open()
446 | :zlib.inflateInit(z)
447 | inflated = :zlib.inflate(z, data)
448 | :zlib.inflateEnd(z)
449 | :zlib.close(z)
450 | inflated
451 | end
452 |
453 | defp contents({doc, page}) do
454 | import Document
455 |
456 | doc
457 | |> add(ContentStream.new(compress: doc.compress, page: page.value))
458 | |> update(page, fn contents, %Page{} = p ->
459 | %{p | contents: contents}
460 | end)
461 | |> finish(& &1.value.contents)
462 | end
463 |
464 | defp text_block(doc, writes, top_level_opts) do
465 | Enum.reduce(writes, Mudbrick.TextBlock.new(top_level_opts), fn
466 | {text, opts}, acc ->
467 | Mudbrick.TextBlock.write(acc, text, fetch_font(doc, opts))
468 |
469 | text, acc ->
470 | Mudbrick.TextBlock.write(acc, text, [])
471 | end)
472 | end
473 |
474 | @spec cm_opts(Mudbrick.Image.t(), Image.image_options()) :: Mudbrick.ContentStream.Cm.options()
475 | defp cm_opts(image, image_opts) do
476 | scale =
477 | case image_opts[:scale] do
478 | {:auto, :auto} ->
479 | raise Mudbrick.Image.AutoScalingError,
480 | "Auto scaling works with width or height, but not both."
481 |
482 | {w, :auto} ->
483 | scaled_height(w, image)
484 |
485 | {:auto, h} ->
486 | scaled_width(h, image)
487 |
488 | nil ->
489 | scaled_height(100, image)
490 |
491 | otherwise ->
492 | otherwise
493 | end
494 |
495 | Keyword.put(image_opts, :scale, scale)
496 | end
497 |
498 | defp scaled_height(w, image) do
499 | ratio = w / image.width
500 | {w, image.height * ratio}
501 | end
502 |
503 | defp scaled_width(h, image) do
504 | ratio = h / image.height
505 | {image.width * ratio, h}
506 | end
507 |
508 | defp fetch_font(doc, opts) do
509 | default_font =
510 | case Map.values(Document.root_page_tree(doc).value.fonts) do
511 | [font] -> font.value
512 | _ -> nil
513 | end
514 |
515 | Keyword.update(opts, :font, default_font, fn user_identifier ->
516 | case Map.fetch(Document.root_page_tree(doc).value.fonts, user_identifier) do
517 | {:ok, font} ->
518 | font.value
519 |
520 | :error ->
521 | raise Font.Unregistered, "Unregistered font: #{user_identifier}"
522 | end
523 | end)
524 | end
525 | end
526 |
--------------------------------------------------------------------------------
/lib/mudbrick/catalog.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Catalog do
2 | @moduledoc false
3 |
4 | @enforce_keys [:metadata, :page_tree]
5 | defstruct [:metadata, :page_tree]
6 |
7 | def new(opts) do
8 | struct!(__MODULE__, opts)
9 | end
10 |
11 | defimpl Mudbrick.Object do
12 | def to_iodata(catalog) do
13 | Mudbrick.Object.to_iodata(%{
14 | Type: :Catalog,
15 | Metadata: catalog.metadata.ref,
16 | Pages: catalog.page_tree.ref
17 | })
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream do
2 | @moduledoc false
3 |
4 | alias Mudbrick.Document
5 |
6 | @enforce_keys [:page]
7 | defstruct compress: false,
8 | operations: [],
9 | page: nil
10 |
11 | defmodule InvalidColour do
12 | defexception [:message]
13 | end
14 |
15 | def new(opts \\ []) do
16 | struct!(__MODULE__, opts)
17 | end
18 |
19 | def add(context, nil) do
20 | context
21 | end
22 |
23 | def add(context, operation) do
24 | update_operations(context, fn operations ->
25 | [operation | operations]
26 | end)
27 | end
28 |
29 | def add(context, mod, opts) do
30 | update_operations(context, fn operations ->
31 | [struct!(mod, opts) | operations]
32 | end)
33 | end
34 |
35 | def put(context, fields) do
36 | update(context, fn contents ->
37 | struct!(contents, fields)
38 | end)
39 | end
40 |
41 | def update_operations(context, f) do
42 | update(context, fn contents ->
43 | Map.update!(contents, :operations, f)
44 | end)
45 | end
46 |
47 | defp update({doc, contents_obj}, f) do
48 | Document.update(doc, contents_obj, f)
49 | end
50 |
51 | defimpl Mudbrick.Object do
52 | def to_iodata(content_stream) do
53 | Mudbrick.Stream.new(
54 | compress: content_stream.compress,
55 | data: [
56 | content_stream.operations
57 | |> Enum.reverse()
58 | |> Mudbrick.join("\n")
59 | ]
60 | )
61 | |> Mudbrick.Object.to_iodata()
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/bt_et.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.BT do
2 | @moduledoc false
3 | defstruct []
4 |
5 | defimpl Mudbrick.Object do
6 | def to_iodata(_), do: ["BT"]
7 | end
8 | end
9 |
10 | defmodule Mudbrick.ContentStream.ET do
11 | @moduledoc false
12 | defstruct []
13 |
14 | defimpl Mudbrick.Object do
15 | def to_iodata(_), do: ["ET"]
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/cm.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.Cm do
2 | @moduledoc false
3 |
4 | @type option ::
5 | {:position, Mudbrick.coords()}
6 | | {:scale, Mudbrick.coords()}
7 | | {:skew, Mudbrick.coords()}
8 |
9 | @type options :: [option()]
10 |
11 | @type t :: %__MODULE__{
12 | position: Mudbrick.coords(),
13 | scale: Mudbrick.coords(),
14 | skew: Mudbrick.coords()
15 | }
16 |
17 | defstruct scale: {0, 0},
18 | skew: {0, 0},
19 | position: {0, 0}
20 |
21 | @spec new(options()) :: t()
22 | def new(opts) do
23 | struct!(__MODULE__, opts)
24 | end
25 |
26 | defimpl Mudbrick.Object do
27 | def to_iodata(%Mudbrick.ContentStream.Cm{
28 | scale: {x_scale, y_scale},
29 | skew: {x_skew, y_skew},
30 | position: {x_translate, y_translate}
31 | }) do
32 | [
33 | Mudbrick.join([x_scale, x_skew, y_skew, y_scale, x_translate, y_translate]),
34 | " cm"
35 | ]
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/do.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.Do do
2 | @moduledoc false
3 | defstruct [:image_identifier]
4 |
5 | defimpl Mudbrick.Object do
6 | def to_iodata(operator) do
7 | [
8 | Mudbrick.Object.to_iodata(operator.image_identifier),
9 | " Do"
10 | ]
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/l.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.L do
2 | @moduledoc false
3 |
4 | @enforce_keys :coords
5 | defstruct [:coords]
6 |
7 | defimpl Mudbrick.Object do
8 | def to_iodata(%Mudbrick.ContentStream.L{coords: {x, y}}) do
9 | Enum.map_intersperse([x, y, "l"], " ", &to_string/1)
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/m.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.M do
2 | @moduledoc false
3 |
4 | @enforce_keys :coords
5 | defstruct [:coords]
6 |
7 | defimpl Mudbrick.Object do
8 | def to_iodata(%Mudbrick.ContentStream.M{coords: {x, y}}) do
9 | Enum.map_intersperse([x, y, "m"], " ", &to_string/1)
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/q.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.QPush do
2 | @moduledoc false
3 | defstruct []
4 |
5 | defimpl Mudbrick.Object do
6 | def to_iodata(_), do: ["q"]
7 | end
8 | end
9 |
10 | defmodule Mudbrick.ContentStream.QPop do
11 | @moduledoc false
12 | defstruct []
13 |
14 | defimpl Mudbrick.Object do
15 | def to_iodata(_), do: ["Q"]
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/re.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.Re do
2 | @moduledoc false
3 |
4 | @enforce_keys [:lower_left, :dimensions]
5 | defstruct [:lower_left, :dimensions]
6 |
7 | defimpl Mudbrick.Object do
8 | def to_iodata(%Mudbrick.ContentStream.Re{lower_left: {x, y}, dimensions: {width, height}}) do
9 | Enum.map_intersperse([x, y, width, height, "re"], " ", &to_string/1)
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/rg.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.Rg do
2 | @moduledoc false
3 | @enforce_keys [:r, :g, :b]
4 | defstruct stroking: false,
5 | r: nil,
6 | g: nil,
7 | b: nil
8 |
9 | def new(opts) do
10 | if Enum.any?(Keyword.delete(opts, :stroking), fn {_k, v} ->
11 | v < 0 or v > 1
12 | end) do
13 | raise Mudbrick.ContentStream.InvalidColour,
14 | "tuple must be made of floats or integers between 0 and 1"
15 | end
16 |
17 | struct!(__MODULE__, opts)
18 | end
19 |
20 | defimpl Mudbrick.Object do
21 | def to_iodata(%Mudbrick.ContentStream.Rg{stroking: false, r: r, g: g, b: b}) do
22 | [[r, g, b] |> Enum.map_join(" ", &to_string/1), " rg"]
23 | end
24 |
25 | def to_iodata(%Mudbrick.ContentStream.Rg{stroking: true, r: r, g: g, b: b}) do
26 | [[r, g, b] |> Enum.map_join(" ", &to_string/1), " RG"]
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/s.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.S do
2 | @moduledoc false
3 |
4 | defstruct []
5 |
6 | defimpl Mudbrick.Object do
7 | def to_iodata(_op) do
8 | ["S"]
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/t_star.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.TStar do
2 | @moduledoc false
3 | defstruct []
4 |
5 | defimpl Mudbrick.Object do
6 | def to_iodata(_), do: ["T*"]
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/td.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.Td do
2 | @moduledoc false
3 | defstruct tx: 0,
4 | ty: 0
5 |
6 | def add_current({_doc, content_stream} = context) do
7 | Mudbrick.ContentStream.add(context, content_stream.value.current_base_td)
8 | end
9 |
10 | def most_recent(content_stream) do
11 | Enum.find(content_stream.value.operations, &match?(%__MODULE__{}, &1))
12 | end
13 |
14 | defimpl Mudbrick.Object do
15 | def to_iodata(td) do
16 | [td.tx, td.ty, "Td"]
17 | |> Enum.map(&to_string/1)
18 | |> Enum.intersperse(" ")
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/tf.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.Tf do
2 | @moduledoc false
3 | @enforce_keys [:font_identifier, :size]
4 | defstruct [:font_identifier, :size]
5 |
6 | defimpl Mudbrick.Object do
7 | def to_iodata(tf) do
8 | [
9 | Mudbrick.Object.to_iodata(tf.font_identifier),
10 | " ",
11 | to_string(tf.size),
12 | " Tf"
13 | ]
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/tj.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.TJ do
2 | @moduledoc false
3 | defstruct auto_kern: true,
4 | kerned_text: []
5 |
6 | defimpl Mudbrick.Object do
7 | def to_iodata(%Mudbrick.ContentStream.TJ{kerned_text: []}) do
8 | []
9 | end
10 |
11 | def to_iodata(op) do
12 | ["[ ", Enum.map(op.kerned_text, &write_glyph(op, &1)), "] TJ"]
13 | end
14 |
15 | defp write_glyph(%{auto_kern: true} = op, {glyph_id, kerning}) do
16 | write_glyph(op, glyph_id) ++ [to_string(kerning), " "]
17 | end
18 |
19 | defp write_glyph(op, {glyph_id, _kerning}) do
20 | write_glyph(op, glyph_id)
21 | end
22 |
23 | defp write_glyph(_op, glyph_id) do
24 | ["<", glyph_id, "> "]
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/tl.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.TL do
2 | @moduledoc false
3 | @enforce_keys [:leading]
4 | defstruct [:leading]
5 |
6 | defimpl Mudbrick.Object do
7 | def to_iodata(tl) do
8 | [to_string(tl.leading), " TL"]
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/mudbrick/content_stream/w.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ContentStream.W do
2 | @moduledoc false
3 |
4 | defstruct [:width]
5 |
6 | defimpl Mudbrick.Object do
7 | def to_iodata(w) do
8 | [to_string(w.width), " w"]
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/mudbrick/document.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Document do
2 | @type t :: %__MODULE__{
3 | compress: boolean(),
4 | objects: list()
5 | }
6 |
7 | defstruct compress: false,
8 | objects: []
9 |
10 | alias Mudbrick.Catalog
11 | alias Mudbrick.Document
12 | alias Mudbrick.Font
13 | alias Mudbrick.Image
14 | alias Mudbrick.Indirect
15 | alias Mudbrick.Metadata
16 | alias Mudbrick.PageTree
17 | alias Mudbrick.Stream
18 |
19 | @type option ::
20 | {:compress, boolean()}
21 | | {:fonts, map()}
22 | | {:images, map()}
23 | | {:producer, String.t()}
24 | | {:creator_tool, String.t()}
25 | | {:create_date, DateTime.t()}
26 | | {:modify_date, DateTime.t()}
27 | | {:title, String.t()}
28 | | {:creators, list(String.t())}
29 |
30 | @type options :: [option()]
31 |
32 | @doc false
33 | @spec new(options()) :: t()
34 | def new(opts) do
35 | compress = Keyword.get(opts, :compress, false)
36 |
37 | {doc, font_objects} =
38 | Font.add_objects(
39 | %Document{compress: compress},
40 | Keyword.get(opts, :fonts, [])
41 | )
42 |
43 | {doc, image_objects} =
44 | Image.add_objects(doc, Keyword.get(opts, :images, []))
45 |
46 | doc
47 | |> add([
48 | PageTree.new(fonts: font_objects, images: image_objects),
49 | Stream.new(
50 | compress: compress,
51 | data: Metadata.render(opts),
52 | additional_entries: %{
53 | Type: :Metadata,
54 | Subtype: :XML
55 | }
56 | )
57 | ])
58 | |> add(fn [page_tree, metadata] ->
59 | Catalog.new(page_tree: page_tree, metadata: metadata)
60 | end)
61 | |> finish()
62 | end
63 |
64 | @doc false
65 | def add(%Document{} = doc, values) when is_list(values) do
66 | List.foldr(values, {doc, []}, fn value, {doc, objects} ->
67 | {doc, obj} = add(doc, value)
68 | {doc, [obj | objects]}
69 | end)
70 | end
71 |
72 | @doc false
73 | def add({doc, just_added_object}, fun) when is_function(fun) do
74 | add(doc, fun.(just_added_object))
75 | end
76 |
77 | @doc false
78 | def add(%Document{objects: objects} = doc, value) do
79 | object = next_object(doc, value)
80 | {%Document{doc | objects: [object | objects]}, object}
81 | end
82 |
83 | @doc false
84 | def add_page_ref(%Indirect.Object{value: tree} = tree_obj, page) do
85 | %{tree_obj | value: PageTree.add_page_ref(tree, page.ref)}
86 | end
87 |
88 | @doc false
89 | def update({doc, just_added_object}, object, fun) do
90 | put(doc, %{object | value: fun.(just_added_object, object.value)})
91 | end
92 |
93 | @doc false
94 | def update(doc, object, fun) do
95 | put(doc, %{object | value: fun.(object.value)})
96 | end
97 |
98 | @doc false
99 | def finish({doc, object}, fun) do
100 | {doc, fun.(object)}
101 | end
102 |
103 | @doc false
104 | def finish({doc, _objects}) do
105 | doc
106 | end
107 |
108 | @doc false
109 | def finish(doc) do
110 | doc
111 | end
112 |
113 | @doc false
114 | def root_page_tree(doc) do
115 | find_object(doc, &match?(%PageTree{}, &1))
116 | end
117 |
118 | @doc false
119 | def update_root_page_tree(doc, fun) do
120 | Map.update!(doc, :objects, fn objects ->
121 | update_in(objects, [Access.find(&match?(%PageTree{}, &1.value))], &fun.(&1))
122 | end)
123 | end
124 |
125 | @doc false
126 | def catalog(doc) do
127 | find_object(doc, &match?(%Catalog{}, &1))
128 | end
129 |
130 | @doc false
131 | def object_with_ref(doc, ref) do
132 | Enum.at(doc.objects, -ref.number)
133 | end
134 |
135 | @doc false
136 | def find_object(%Document{objects: objects}, f) do
137 | Enum.find(objects, fn object ->
138 | f.(object.value)
139 | end)
140 | end
141 |
142 | defp put(doc, updated_object) do
143 | {
144 | Map.update!(doc, :objects, fn objects ->
145 | List.replace_at(objects, -updated_object.ref.number, updated_object)
146 | end),
147 | updated_object
148 | }
149 | end
150 |
151 | defp next_object(doc, value) do
152 | doc |> next_ref() |> Indirect.Object.new(value)
153 | end
154 |
155 | defp next_ref(doc) do
156 | doc |> next_object_number() |> Indirect.Ref.new()
157 | end
158 |
159 | defp next_object_number(doc) do
160 | length(doc.objects) + 1
161 | end
162 | end
163 |
--------------------------------------------------------------------------------
/lib/mudbrick/font.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Font do
2 | @type t :: %__MODULE__{
3 | descendant: Mudbrick.Indirect.Object.t(),
4 | encoding: atom(),
5 | name: atom(),
6 | resource_identifier: atom(),
7 | to_unicode: Mudbrick.Indirect.Object.t(),
8 | type: atom(),
9 | parsed: map()
10 | }
11 |
12 | @enforce_keys [
13 | :name,
14 | :resource_identifier,
15 | :type
16 | ]
17 |
18 | defstruct [
19 | :descendant,
20 | :encoding,
21 | :name,
22 | :resource_identifier,
23 | :to_unicode,
24 | :type,
25 | :parsed
26 | ]
27 |
28 | defmodule MustBeChosen do
29 | defexception message: "You must choose a font unless you have exactly one font"
30 | end
31 |
32 | defmodule Unregistered do
33 | defexception [:message]
34 | end
35 |
36 | alias __MODULE__
37 | alias Mudbrick.Document
38 | alias Mudbrick.Font.CMap
39 | alias Mudbrick.Object
40 | alias Mudbrick.Stream
41 |
42 | @doc false
43 | def new(opts) do
44 | case Keyword.fetch(opts, :parsed) do
45 | {:ok, parsed} ->
46 | {:name, font_type} = Map.fetch!(parsed, "SubType")
47 | struct!(__MODULE__, Keyword.put(opts, :type, type!(font_type)))
48 |
49 | :error ->
50 | struct!(__MODULE__, opts)
51 | end
52 | end
53 |
54 | @doc false
55 | def type!(s), do: Map.fetch!(%{"Type0" => :Type0}, s)
56 |
57 | @doc false
58 | def add_objects(doc, fonts) do
59 | sorted_fonts = fonts |> Enum.into([]) |> Enum.sort()
60 |
61 | {doc, font_objects, _id} =
62 | for {human_name, file_contents} <- sorted_fonts, reduce: {doc, %{}, 0} do
63 | {doc, font_objects, id} ->
64 | font_opts =
65 | [resource_identifier: :"F#{id + 1}"]
66 |
67 | opentype =
68 | OpenType.new()
69 | |> OpenType.parse(file_contents)
70 |
71 | font_name = String.to_atom(opentype.name)
72 |
73 | {doc, font} =
74 | doc
75 | |> add_font_file(file_contents)
76 | |> add_descriptor(opentype, font_name)
77 | |> add_cid_font(opentype, font_name)
78 | |> add_font(opentype, font_name, font_opts)
79 |
80 | {doc, Map.put(font_objects, human_name, font), id + 1}
81 | end
82 |
83 | {doc, font_objects}
84 | end
85 |
86 | @doc false
87 | def width(_font, _size, "", _opts) do
88 | 0
89 | end
90 |
91 | def width(font, size, text, opts) do
92 | {glyph_ids, positions} = OpenType.layout_text(font.parsed, text)
93 |
94 | widths =
95 | if Keyword.get(opts, :auto_kern) do
96 | Enum.map(positions, fn {_, _, _, width, _} -> width end)
97 | else
98 | Enum.map(glyph_ids, &Enum.at(font.parsed.glyphWidths, &1))
99 | end
100 |
101 | Enum.reduce(widths, 0, fn width, acc ->
102 | acc + width / 1000 * size
103 | end)
104 | end
105 |
106 | @doc false
107 | def kerned(font, text) do
108 | {glyph_ids_decimal, positions} =
109 | OpenType.layout_text(font.parsed, text)
110 |
111 | glyph_ids_decimal
112 | |> Enum.zip(positions)
113 | |> Enum.map(fn
114 | {glyph_id, {:kern, _, _, width_when_kerned, _}} ->
115 | normal_width = Enum.at(font.parsed.glyphWidths, glyph_id)
116 | offset = normal_width - width_when_kerned
117 | {Mudbrick.to_hex(glyph_id), offset}
118 |
119 | {glyph_id, {:std_width, _, _, _width, _}} ->
120 | Mudbrick.to_hex(glyph_id)
121 | end)
122 | end
123 |
124 | defp add_font_file(doc, contents) do
125 | doc
126 | |> Document.add(
127 | Stream.new(
128 | compress: doc.compress,
129 | data: contents,
130 | additional_entries: %{
131 | Length1: byte_size(contents),
132 | Subtype: :OpenType
133 | }
134 | )
135 | )
136 | end
137 |
138 | defp add_descriptor(doc, opentype, font_name) do
139 | doc
140 | |> Document.add(
141 | &Font.Descriptor.new(
142 | ascent: opentype.ascent,
143 | cap_height: opentype.capHeight,
144 | descent: opentype.descent,
145 | file: &1,
146 | flags: opentype.flags,
147 | font_name: font_name,
148 | bounding_box: opentype.bbox,
149 | italic_angle: opentype.italicAngle,
150 | stem_vertical: opentype.stemV
151 | )
152 | )
153 | end
154 |
155 | defp add_cid_font(doc, opentype, font_name) do
156 | doc
157 | |> Document.add(
158 | &Font.CIDFont.new(
159 | default_width: opentype.defaultWidth,
160 | descriptor: &1,
161 | type: :CIDFontType0,
162 | font_name: font_name,
163 | widths: opentype.glyphWidths
164 | )
165 | )
166 | end
167 |
168 | defp add_font({doc, cid_font}, opentype, font_name, font_opts) do
169 | doc
170 | |> Document.add(CMap.new(compress: doc.compress, parsed: opentype))
171 | |> Document.add(fn cmap ->
172 | Font.new(
173 | Keyword.merge(font_opts,
174 | descendant: cid_font,
175 | encoding: :"Identity-H",
176 | name: font_name,
177 | parsed: opentype,
178 | to_unicode: cmap
179 | )
180 | )
181 | end)
182 | end
183 |
184 | defimpl Mudbrick.Object do
185 | def to_iodata(font) do
186 | Object.to_iodata(%{
187 | Type: :Font,
188 | BaseFont: font.name,
189 | Subtype: font.type,
190 | Encoding: font.encoding,
191 | DescendantFonts: [font.descendant.ref],
192 | ToUnicode: font.to_unicode.ref
193 | })
194 | end
195 | end
196 | end
197 |
--------------------------------------------------------------------------------
/lib/mudbrick/font/cid_font.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Font.CIDFont do
2 | @moduledoc false
3 |
4 | @enforce_keys [
5 | :default_width,
6 | :descriptor,
7 | :font_name,
8 | :type,
9 | :widths
10 | ]
11 | defstruct [
12 | :default_width,
13 | :descriptor,
14 | :font_name,
15 | :type,
16 | :widths
17 | ]
18 |
19 | def new(opts) do
20 | struct!(__MODULE__, opts)
21 | end
22 |
23 | defimpl Mudbrick.Object do
24 | def to_iodata(cid_font) do
25 | Mudbrick.Object.to_iodata(%{
26 | Type: :Font,
27 | Subtype: cid_font.type,
28 | BaseFont: cid_font.font_name,
29 | CIDSystemInfo: %{
30 | Registry: "Adobe",
31 | Ordering: "Identity",
32 | Supplement: 0
33 | },
34 | FontDescriptor: cid_font.descriptor.ref,
35 | DW: cid_font.default_width,
36 | W: [0, cid_font.widths]
37 | })
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/mudbrick/font/cmap.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Font.CMap do
2 | @moduledoc false
3 |
4 | defstruct compress: false,
5 | parsed: nil
6 |
7 | def new(opts) do
8 | struct!(__MODULE__, opts)
9 | end
10 |
11 | defimpl Mudbrick.Object do
12 | def to_iodata(cmap) do
13 | pairs =
14 | cmap.parsed.gid2cid
15 | |> Enum.map(fn {gid, cid} ->
16 | ["<", Mudbrick.to_hex(gid), "> <", Mudbrick.to_hex(cid), ">\n"]
17 | end)
18 | |> Enum.sort()
19 |
20 | data = [
21 | """
22 | /CIDInit /ProcSet findresource begin
23 | 12 dict begin
24 | begincmap
25 | /CIDSystemInfo
26 | <> def
30 | /CMapName /Adobe-Identity-UCS def
31 | /CMapType 2 def
32 | 1 begincodespacerange
33 | <0000>
34 | endcodespacerange
35 | """,
36 | pairs |> length() |> to_string(),
37 | """
38 | beginbfchar
39 | """,
40 | pairs,
41 | """
42 | endbfchar
43 | endcmap
44 | CMapName currentdict /CMap defineresource pop
45 | end
46 | end\
47 | """
48 | ]
49 |
50 | Mudbrick.Object.to_iodata(
51 | Mudbrick.Stream.new(
52 | compress: cmap.compress,
53 | data: data
54 | )
55 | )
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/mudbrick/font/descriptor.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Font.Descriptor do
2 | @moduledoc false
3 |
4 | @enforce_keys [
5 | :ascent,
6 | :bounding_box,
7 | :cap_height,
8 | :descent,
9 | :file,
10 | :flags,
11 | :font_name,
12 | :italic_angle,
13 | :stem_vertical
14 | ]
15 | defstruct [
16 | :ascent,
17 | :bounding_box,
18 | :cap_height,
19 | :descent,
20 | :file,
21 | :flags,
22 | :font_name,
23 | :italic_angle,
24 | :stem_vertical
25 | ]
26 |
27 | def new(opts) do
28 | struct!(__MODULE__, opts)
29 | end
30 |
31 | defimpl Mudbrick.Object do
32 | def to_iodata(descriptor) do
33 | Mudbrick.Object.to_iodata(%{
34 | Ascent: descriptor.ascent,
35 | CapHeight: descriptor.cap_height,
36 | Descent: descriptor.descent,
37 | Flags: descriptor.flags,
38 | FontBBox: descriptor.bounding_box,
39 | FontFile3: descriptor.file.ref,
40 | FontName: descriptor.font_name,
41 | ItalicAngle: descriptor.italic_angle,
42 | StemV: descriptor.stem_vertical,
43 | Type: :FontDescriptor
44 | })
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/mudbrick/image.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Image do
2 | @type t :: %__MODULE__{
3 | file: iodata(),
4 | resource_identifier: atom(),
5 | width: number(),
6 | height: number(),
7 | bits_per_component: number(),
8 | filter: :DCTDecode
9 | }
10 |
11 | @type scale_dimension :: number() | :auto
12 | @type scale :: {scale_dimension(), scale_dimension()}
13 | @type image_option ::
14 | {:position, Mudbrick.coords()}
15 | | {:scale, scale()}
16 | | {:skew, Mudbrick.coords()}
17 | @type image_options :: [image_option()]
18 |
19 | @enforce_keys [:file, :resource_identifier]
20 | defstruct [
21 | :file,
22 | :resource_identifier,
23 | :width,
24 | :height,
25 | :bits_per_component,
26 | :filter
27 | ]
28 |
29 | defmodule AutoScalingError do
30 | defexception [:message]
31 | end
32 |
33 | defmodule Unregistered do
34 | defexception [:message]
35 | end
36 |
37 | defmodule NotSupported do
38 | defexception [:message]
39 | end
40 |
41 | alias Mudbrick.Document
42 | alias Mudbrick.Stream
43 |
44 | @doc false
45 | @spec new(Keyword.t()) :: t()
46 | def new(opts) do
47 | struct!(
48 | __MODULE__,
49 | Keyword.merge(
50 | opts,
51 | file_dependent_opts(ExImageInfo.info(opts[:file]))
52 | )
53 | )
54 | end
55 |
56 | @doc false
57 | def add_objects(doc, images) do
58 | {doc, image_objects, _id} =
59 | for {human_name, image_data} <- images, reduce: {doc, %{}, 0} do
60 | {doc, image_objects, id} ->
61 | {doc, image} =
62 | Document.add(
63 | doc,
64 | new(file: image_data, resource_identifier: :"I#{id + 1}")
65 | )
66 |
67 | {doc, Map.put(image_objects, human_name, image), id + 1}
68 | end
69 |
70 | {doc, image_objects}
71 | end
72 |
73 | defp file_dependent_opts({"image/jpeg", width, height, _variant}) do
74 | [
75 | width: width,
76 | height: height,
77 | filter: :DCTDecode,
78 | bits_per_component: 8
79 | ]
80 | end
81 |
82 | defp file_dependent_opts({"image/png", _width, _height, _variant}) do
83 | raise NotSupported, "PNGs are currently not supported"
84 | end
85 |
86 | defimpl Mudbrick.Object do
87 | def to_iodata(image) do
88 | Stream.new(
89 | data: image.file,
90 | additional_entries: %{
91 | Type: :XObject,
92 | Subtype: :Image,
93 | Width: image.width,
94 | Height: image.height,
95 | BitsPerComponent: image.bits_per_component,
96 | ColorSpace: :DeviceRGB,
97 | Filter: image.filter
98 | }
99 | )
100 | |> Mudbrick.Object.to_iodata()
101 | end
102 | end
103 | end
104 |
--------------------------------------------------------------------------------
/lib/mudbrick/indirect.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Indirect do
2 | @moduledoc false
3 |
4 | defmodule Ref do
5 | @type t :: %__MODULE__{
6 | number: number()
7 | }
8 |
9 | defstruct [:number]
10 |
11 | @doc false
12 | def new(number) do
13 | %__MODULE__{number: number}
14 | end
15 |
16 | defimpl Mudbrick.Object do
17 | def to_iodata(%Ref{number: number}) do
18 | [to_string(number), " 0 R"]
19 | end
20 | end
21 | end
22 |
23 | defmodule Object do
24 | @type t :: %__MODULE__{
25 | value: term(),
26 | ref: Ref.t()
27 | }
28 |
29 | defstruct [:value, :ref]
30 |
31 | @doc false
32 | def new(ref, value) do
33 | %__MODULE__{value: value, ref: ref}
34 | end
35 |
36 | defimpl Mudbrick.Object do
37 | def to_iodata(%Object{value: value, ref: ref}) do
38 | [to_string(ref.number), " 0 obj\n", Mudbrick.Object.to_iodata(value), "\nendobj"]
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/mudbrick/metadata.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Metadata do
2 | @moduledoc false
3 |
4 | def render(opts) do
5 | opts = normalised_opts(opts)
6 | document_id = id(opts)
7 | instance_id = id([{:generate, :instance_id} | opts])
8 |
9 | [
10 | """
11 |
12 |
13 |
14 |
15 | \
16 | """,
17 | Keyword.fetch!(opts, :producer),
18 | """
19 |
20 |
21 |
22 | \
23 | """,
24 | Keyword.fetch!(opts, :creator_tool),
25 | """
26 |
27 | """,
28 | date("CreateDate", opts[:create_date]),
29 | date("ModifyDate", opts[:modify_date]),
30 | """
31 |
32 |
33 | application/pdf
34 |
35 |
36 | \
37 | """,
38 | Keyword.fetch!(opts, :title),
39 | """
40 |
41 |
42 |
43 | """,
44 | creators(opts[:creators]),
45 | """
46 |
47 |
48 | \
49 | """,
50 | document_id,
51 | """
52 |
53 | \
54 | """,
55 | instance_id,
56 | """
57 |
58 |
59 |
60 |
61 |
62 | \
63 | """
64 | ]
65 | end
66 |
67 | defp creators([]), do: ""
68 | defp creators(nil), do: ""
69 |
70 | defp creators(creators) do
71 | [
72 | """
73 |
74 |
75 | """,
76 | for(name <- creators, do: ["", name, ""]),
77 | """
78 |
79 |
80 | """
81 | ]
82 | end
83 |
84 | defp date(_, nil), do: ""
85 | defp date(_, ""), do: ""
86 |
87 | defp date(element_name, date) do
88 | ["", DateTime.to_iso8601(date), ""]
89 | end
90 |
91 | defp id(opts) do
92 | {fonts, opts} = hashable_resources(opts, :fonts)
93 | {images, opts} = hashable_resources(opts, :images)
94 |
95 | :crypto.hash(
96 | :sha256,
97 | [
98 | Application.spec(:mudbrick)[:vsn],
99 | inspect(opts),
100 | fonts,
101 | images
102 | ]
103 | )
104 | |> Base.encode64(padding: false)
105 | end
106 |
107 | defp hashable_resources(opts, type) do
108 | case Keyword.pop(opts, type) do
109 | {nil, opts} ->
110 | {"", opts}
111 |
112 | {resources, opts} ->
113 | {Map.values(resources) |> Enum.sort(), opts}
114 | end
115 | end
116 |
117 | defp normalised_opts(opts) do
118 | Keyword.merge(opts,
119 | compress: Keyword.get(opts, :compress, false),
120 | create_date: empty_string_opt(opts, :create_date),
121 | creators: empty_string_opt(opts, :creators, []),
122 | creator_tool: Keyword.get(opts, :creator_tool, "Mudbrick"),
123 | modify_date: empty_string_opt(opts, :modify_date),
124 | producer: Keyword.get(opts, :producer, "Mudbrick"),
125 | title: empty_string_opt(opts, :title, "")
126 | )
127 | end
128 |
129 | defp empty_string_opt(opts, key, default \\ nil) do
130 | case Keyword.get(opts, key, default) do
131 | "" -> default
132 | otherwise -> otherwise
133 | end
134 | end
135 | end
136 |
--------------------------------------------------------------------------------
/lib/mudbrick/page.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Page do
2 | @type t :: %__MODULE__{
3 | contents: Mudbrick.Indirect.Object.t(),
4 | parent: Mudbrick.Indirect.Ref.t(),
5 | size: Mudbrick.coords()
6 | }
7 |
8 | defstruct contents: nil,
9 | parent: nil,
10 | size: nil
11 |
12 | alias Mudbrick.Document
13 |
14 | @dpi 72
15 |
16 | @page_sizes %{
17 | a3: {11.7 * @dpi, 16.5 * @dpi},
18 | a4: {8.3 * @dpi, 11.7 * @dpi},
19 | letter: {8.5 * @dpi, 11 * @dpi}
20 | }
21 |
22 | @type option :: {:size, Mudbrick.coords()}
23 | @type options :: [option()]
24 |
25 | @doc false
26 | @spec new(options()) :: t()
27 | def new(opts \\ []) do
28 | struct!(__MODULE__, opts)
29 | end
30 |
31 | @doc """
32 | Returns predefined page sizes in points.
33 |
34 | ## Examples
35 |
36 | iex> Mudbrick.Page.size(:a4)
37 | {597.6, 842.4}
38 |
39 | iex> Mudbrick.Page.size(:a3)
40 | {842.4, 1188.0}
41 |
42 | iex> Mudbrick.Page.size(:letter)
43 | {612.0, 792}
44 | """
45 |
46 | @spec size(name :: atom()) :: Mudbrick.coords()
47 | def size(name) do
48 | @page_sizes[name]
49 | end
50 |
51 | @doc false
52 | def add(doc, opts) do
53 | add_empty_page(doc, opts)
54 | |> add_to_page_tree()
55 | end
56 |
57 | defp add_empty_page(doc, opts) do
58 | Document.add(doc, new_at_root(opts, doc))
59 | end
60 |
61 | defp new_at_root(opts, doc) do
62 | Keyword.put(opts, :parent, Document.root_page_tree(doc).ref) |> new()
63 | end
64 |
65 | defp add_to_page_tree({doc, page}) do
66 | {
67 | Document.update_root_page_tree(doc, fn page_tree ->
68 | Document.add_page_ref(page_tree, page)
69 | end),
70 | page
71 | }
72 | end
73 |
74 | defimpl Mudbrick.Object do
75 | def to_iodata(page) do
76 | {width, height} = page.size
77 |
78 | Mudbrick.Object.to_iodata(
79 | %{
80 | Type: :Page,
81 | Parent: page.parent,
82 | MediaBox: [0, 0, width, height]
83 | }
84 | |> Map.merge(
85 | case page.contents do
86 | nil -> %{}
87 | contents -> %{Contents: contents.ref}
88 | end
89 | )
90 | )
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/lib/mudbrick/page_tree.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.PageTree do
2 | @moduledoc false
3 |
4 | defstruct fonts: %{},
5 | images: %{},
6 | kids: []
7 |
8 | def new(opts \\ []) do
9 | struct!(__MODULE__, opts)
10 | end
11 |
12 | def add_page_ref(tree, ref) do
13 | %{tree | kids: tree.kids ++ [ref]}
14 | end
15 |
16 | defimpl Mudbrick.Object do
17 | def to_iodata(page_tree) do
18 | Mudbrick.Object.to_iodata(%{
19 | Type: :Pages,
20 | Kids: page_tree.kids,
21 | Count: length(page_tree.kids),
22 | Resources: %{
23 | Font: resource_dictionary(page_tree.fonts),
24 | XObject: resource_dictionary(page_tree.images)
25 | }
26 | })
27 | end
28 |
29 | defp resource_dictionary(resources) do
30 | for {_human_identifier, object} <- resources, into: %{} do
31 | {object.value.resource_identifier, object.ref}
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/mudbrick/parser.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Parser do
2 | @moduledoc """
3 | Parse documents generated with Mudbrick back into Elixir. Useful for testing.
4 |
5 | Eventually this module may support documents generated with other PDF processors.
6 | """
7 |
8 | import Mudbrick.Parser.AST
9 | import Mudbrick.Parser.Helpers
10 | import NimbleParsec
11 |
12 | alias Mudbrick.ContentStream.{
13 | Tf,
14 | TJ
15 | }
16 |
17 | defmodule Error do
18 | defexception [:message]
19 | end
20 |
21 | @doc """
22 | Parse Mudbrick-generated `iodata` into a `Mudbrick.Document`.
23 |
24 | ## Minimal round-trip
25 |
26 | iex> doc = Mudbrick.new()
27 | ...> doc
28 | ...> |> Mudbrick.render()
29 | ...> |> Mudbrick.Parser.parse()
30 | doc
31 | """
32 | @spec parse(iodata()) :: Mudbrick.Document.t()
33 | def parse(iodata) do
34 | input = IO.iodata_to_binary(iodata)
35 |
36 | case pdf(input) do
37 | {:error, msg, rest, %{}, _things, _bytes} ->
38 | raise Error, "#{msg}\n#{rest}"
39 |
40 | {:ok, parsed_items, _rest, %{}, _, _} ->
41 | items = Enum.flat_map(parsed_items, &to_mudbrick/1)
42 |
43 | page_refs = page_refs(items)
44 | font_files = Enum.filter(items, &font?/1)
45 | image_files = Enum.filter(items, &image?/1)
46 | fonts = decompressed_resources_option(font_files, "F")
47 | images = decompressed_resources_option(image_files, "I")
48 | compress? = Enum.any?(items, &match?(%{value: %{compress: true}}, &1))
49 |
50 | opts =
51 | [compress: compress?] ++
52 | case Enum.find(items, &metadata?/1) do
53 | nil ->
54 | []
55 |
56 | metadata ->
57 | xml =
58 | if metadata.value.compress do
59 | Mudbrick.decompress(metadata.value.data)
60 | else
61 | metadata.value.data
62 | end
63 |
64 | metadata(xml)
65 | end
66 |
67 | opts = if map_size(fonts) > 0, do: Keyword.put(opts, :fonts, fonts), else: opts
68 | opts = if map_size(images) > 0, do: Keyword.put(opts, :images, images), else: opts
69 |
70 | for page <- all(items, page_refs), reduce: Mudbrick.new(opts) do
71 | acc ->
72 | [_, _, w, h] = page.value[:MediaBox]
73 |
74 | Mudbrick.page(acc, size: {w, h})
75 | |> Mudbrick.ContentStream.update_operations(fn ops ->
76 | operations(items, page) ++ ops
77 | end)
78 | end
79 | |> Mudbrick.Document.finish()
80 | end
81 | end
82 |
83 | @doc """
84 | Parse a section of a Mudbrick-generated PDF with a named parsing function.
85 | Mostly useful for debugging this parser.
86 | """
87 | @spec parse(iodata(), atom()) :: term()
88 | def parse(iodata, f) do
89 | case iodata
90 | |> IO.iodata_to_binary()
91 | |> then(&apply(__MODULE__, f, [&1])) do
92 | {:ok, resp, _, %{}, _, _} -> resp
93 | end
94 | end
95 |
96 | @doc """
97 | Extract text content from a Mudbrick-generated PDF. Will map glyphs back to
98 | their original characters.
99 |
100 | ## With compression
101 |
102 | iex> import Mudbrick.TestHelper
103 | ...> import Mudbrick
104 | ...> new(compress: true, fonts: %{bodoni: bodoni_regular(), franklin: franklin_regular()})
105 | ...> |> page()
106 | ...> |> text({"hello, world!", underline: [width: 1]}, font: :bodoni)
107 | ...> |> text("hello in another font", font: :franklin)
108 | ...> |> Mudbrick.render()
109 | ...> |> Mudbrick.Parser.extract_text()
110 | [ "hello, world!", "hello in another font" ]
111 |
112 | ## Without compression
113 |
114 | iex> import Mudbrick.TestHelper
115 | ...> import Mudbrick
116 | ...> new(fonts: %{bodoni: bodoni_regular(), franklin: franklin_regular()})
117 | ...> |> page()
118 | ...> |> text({"hello, world!", underline: [width: 1]}, font: :bodoni)
119 | ...> |> text("hello in another font", font: :franklin)
120 | ...> |> Mudbrick.render()
121 | ...> |> Mudbrick.Parser.extract_text()
122 | [ "hello, world!", "hello in another font" ]
123 |
124 | """
125 | @spec extract_text(iodata()) :: [String.t()]
126 | def extract_text(iodata) do
127 | alias Mudbrick.ContentStream.{Tf, TJ}
128 |
129 | doc = parse(iodata)
130 |
131 | content_stream =
132 | Mudbrick.Document.find_object(doc, &match?(%Mudbrick.ContentStream{}, &1))
133 |
134 | page_tree = Mudbrick.Document.root_page_tree(doc)
135 | fonts = page_tree.value.fonts
136 |
137 | {text_items, _last_found_font} =
138 | content_stream.value.operations
139 | |> List.foldr({[], nil}, fn
140 | %Tf{font_identifier: font_identifier}, {text_items, _current_font} ->
141 | {text_items, Map.fetch!(fonts, font_identifier).value.parsed}
142 |
143 | %TJ{kerned_text: kerned_text}, {text_items, current_font} ->
144 | text =
145 | kerned_text
146 | |> Enum.map(fn
147 | {hex_glyph, _kern} -> hex_glyph
148 | hex_glyph -> hex_glyph
149 | end)
150 | |> Enum.map(fn hex_glyph ->
151 | {decimal_glyph, _} = Integer.parse(hex_glyph, 16)
152 | Map.fetch!(current_font.gid2cid, decimal_glyph)
153 | end)
154 | |> to_string()
155 |
156 | {[text | text_items], current_font}
157 |
158 | _operation, {text_items, current_font} ->
159 | {text_items, current_font}
160 | end)
161 |
162 | Enum.reverse(text_items)
163 | end
164 |
165 | def metadata(xml_iodata) do
166 | {doc, _rest} =
167 | xml_iodata
168 | |> IO.iodata_to_binary()
169 | |> String.replace(~r/^<\?xpacket.*\?>\n/, "")
170 | |> String.to_charlist()
171 | |> :xmerl_scan.string()
172 |
173 | [
174 | create_date: extract_metadata_field(doc, "xmp:CreateDate"),
175 | creator_tool: extract_metadata_field(doc, "xmp:CreatorTool"),
176 | creators: creators(doc),
177 | modify_date: extract_metadata_field(doc, "xmp:ModifyDate"),
178 | producer: extract_metadata_field(doc, "pdf:Producer"),
179 | title: extract_metadata_field(doc, "dc:title/rdf:Alt/rdf:li")
180 | ]
181 | end
182 |
183 | defp creators(doc) do
184 | :xmerl_xpath.string(~c"//dc:creator//rdf:li", doc)
185 | |> Enum.map(fn
186 | {:xmlElement, :"rdf:li", :"rdf:li", {~c"rdf", ~c"li"}, _ns, _attributes, _n, [], [], [],
187 | _path, :undeclared} ->
188 | ""
189 |
190 | {:xmlElement, :"rdf:li", :"rdf:li", {~c"rdf", ~c"li"}, _ns, _attributes, _n, [],
191 | [
192 | {:xmlText, _more_attributes, _1, [], text, :text}
193 | ], [], _path, :undeclared} ->
194 | text |> to_string() |> String.trim()
195 | end)
196 | end
197 |
198 | defp extract_metadata_field(doc, field) when field in ~w(xmp:CreateDate xmp:ModifyDate) do
199 | case extract_meta(doc, field)
200 | |> DateTime.from_iso8601() do
201 | {:ok, datetime, _offset} -> datetime
202 | {:error, _} -> ""
203 | end
204 | end
205 |
206 | defp extract_metadata_field(doc, field) do
207 | extract_meta(doc, field)
208 | end
209 |
210 | defp extract_meta(doc, field) do
211 | :xmerl_xpath.string(~c"//#{field}/text()", doc) |> extract_meta_text()
212 | end
213 |
214 | defp extract_meta_text([]), do: ""
215 |
216 | defp extract_meta_text([{:xmlText, _attributes, 1, [], text, :text}]),
217 | do: text |> to_string() |> String.trim()
218 |
219 | defp extract_meta_text([{:xmlText, _attributes, 1, [], text, :text} | _]) do
220 | text |> to_string() |> String.trim()
221 | end
222 |
223 | @doc false
224 | defparsec(:boolean, boolean())
225 | @doc false
226 | defparsec(:content_blocks, content_blocks())
227 | @doc false
228 | defparsec(:number, number())
229 | @doc false
230 | defparsec(:real, real())
231 | @doc false
232 | defparsec(:string, string())
233 |
234 | @doc false
235 | defparsec(
236 | :array,
237 | ignore(ascii_char([?[]))
238 | |> repeat(
239 | optional(ignore(whitespace()))
240 | |> parsec(:object)
241 | |> optional(ignore(whitespace()))
242 | )
243 | |> ignore(ascii_char([?]]))
244 | |> tag(:array)
245 | )
246 |
247 | @doc false
248 | defparsec(
249 | :dictionary,
250 | ignore(string("<<"))
251 | |> repeat(
252 | optional(ignore(whitespace()))
253 | |> concat(name())
254 | |> ignore(whitespace())
255 | |> parsec(:object)
256 | |> tag(:pair)
257 | )
258 | |> optional(ignore(whitespace()))
259 | |> ignore(string(">>"))
260 | |> tag(:dictionary)
261 | )
262 |
263 | @doc false
264 | defparsec(
265 | :object,
266 | choice([
267 | string(),
268 | name(),
269 | indirect_reference(),
270 | real(),
271 | integer(),
272 | boolean(),
273 | parsec(:array),
274 | parsec(:dictionary)
275 | ])
276 | )
277 |
278 | @doc false
279 | defparsec(
280 | :stream,
281 | parsec(:dictionary)
282 | |> ignore(whitespace())
283 | |> string("stream")
284 | |> ignore(eol())
285 | |> post_traverse({:stream_contents, []})
286 | |> ignore(optional(eol()))
287 | |> ignore(string("endstream"))
288 | )
289 |
290 | @doc false
291 | defparsec(
292 | :indirect_object,
293 | integer(min: 1)
294 | |> ignore(whitespace())
295 | |> integer(min: 1)
296 | |> ignore(whitespace())
297 | |> ignore(string("obj"))
298 | |> ignore(eol())
299 | |> concat(
300 | choice([
301 | boolean(),
302 | parsec(:stream),
303 | parsec(:dictionary)
304 | ])
305 | )
306 | |> ignore(eol())
307 | |> ignore(string("endobj"))
308 | |> tag(:indirect_object)
309 | )
310 |
311 | @doc false
312 | defparsec(
313 | :pdf,
314 | ignore(version())
315 | |> ignore(ascii_string([not: ?\n], min: 1))
316 | |> ignore(eol())
317 | |> repeat(parsec(:indirect_object) |> ignore(whitespace()))
318 | |> ignore(string("xref"))
319 | |> ignore(eol())
320 | |> eventually(ignore(string("trailer") |> concat(eol())))
321 | |> parsec(:dictionary)
322 | )
323 |
324 | @doc false
325 | def stream_contents(
326 | rest,
327 | [
328 | "stream",
329 | {:dictionary, pairs}
330 | ] = results,
331 | context,
332 | _line,
333 | _offset
334 | ) do
335 | dictionary = to_mudbrick({:dictionary, pairs})
336 | bytes_to_read = dictionary[:Length]
337 |
338 | {
339 | binary_slice(rest, bytes_to_read..-1//1),
340 | [binary_slice(rest, 0, bytes_to_read) | results],
341 | context
342 | }
343 | end
344 |
345 | @doc false
346 | def to_mudbrick(iodata, f),
347 | do:
348 | iodata
349 | |> parse(f)
350 | |> to_mudbrick()
351 |
352 | defp decompressed_resources_option(files, prefix) do
353 | files
354 | |> Enum.with_index(fn file, n ->
355 | {
356 | :"#{prefix}#{n + 1}",
357 | file.value.data
358 | |> then(
359 | &if file.value.compress do
360 | &1 |> Mudbrick.decompress() |> IO.iodata_to_binary()
361 | else
362 | &1
363 | end
364 | )
365 | }
366 | end)
367 | |> Enum.into(%{})
368 | end
369 |
370 | defp page_refs(items) do
371 | {:Root, [catalog_ref]} = Enum.find(items, &match?({:Root, _}, &1))
372 | catalog = one(items, catalog_ref)
373 | [page_tree_ref] = catalog.value[:Pages]
374 | page_tree = one(items, page_tree_ref)
375 | List.flatten(page_tree.value[:Kids])
376 | end
377 |
378 | defp operations(items, page) do
379 | [contents_ref] = page.value[:Contents]
380 | contents = one(items, contents_ref)
381 | stream = contents.value
382 |
383 | data =
384 | if stream.compress do
385 | Mudbrick.decompress(stream.data)
386 | else
387 | stream.data
388 | end
389 |
390 | if data == "" do
391 | []
392 | else
393 | case to_mudbrick(data, :content_blocks) do
394 | %Mudbrick.ContentStream{operations: operations} ->
395 | operations
396 |
397 | _ ->
398 | raise Error, "Can't parse content blocks: #{data}"
399 | end
400 | end
401 | end
402 |
403 | defp metadata?(item) do
404 | match?(%{value: %{additional_entries: %{Type: :Metadata}}}, item)
405 | end
406 |
407 | defp font?(item) do
408 | match?(%{value: %{additional_entries: %{Subtype: :OpenType}}}, item)
409 | end
410 |
411 | defp image?(item) do
412 | match?(%{value: %{additional_entries: %{Subtype: :Image}}}, item)
413 | end
414 |
415 | defp one(items, ref) do
416 | Enum.find(items, &match?(%{ref: ^ref}, &1))
417 | end
418 |
419 | defp all(items, refs) do
420 | Enum.filter(items, fn
421 | %{ref: ref} ->
422 | ref in refs
423 |
424 | _ ->
425 | false
426 | end)
427 | end
428 | end
429 |
--------------------------------------------------------------------------------
/lib/mudbrick/parser/ast.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Parser.AST do
2 | @moduledoc false
3 |
4 | alias Mudbrick.ContentStream.{
5 | Cm,
6 | Do,
7 | L,
8 | M,
9 | QPop,
10 | QPush,
11 | Re,
12 | Rg,
13 | Td,
14 | Tf,
15 | TJ,
16 | TL,
17 | W
18 | }
19 |
20 | alias Mudbrick.Indirect
21 |
22 | def to_operator({:cm, [x_scale, x_skew, y_skew, y_scale, x_translate, y_translate]}),
23 | do: %Cm{
24 | scale: {to_mudbrick(x_scale), to_mudbrick(y_scale)},
25 | skew: {to_mudbrick(x_skew), to_mudbrick(y_skew)},
26 | position: {to_mudbrick(x_translate), to_mudbrick(y_translate)}
27 | }
28 |
29 | def to_operator({:re, [x, y, w, h]}),
30 | do: %Re{
31 | lower_left: {to_mudbrick(x), to_mudbrick(y)},
32 | dimensions: {to_mudbrick(w), to_mudbrick(h)}
33 | }
34 |
35 | def to_operator({:m, [x, y]}),
36 | do: %M{
37 | coords: {to_mudbrick(x), to_mudbrick(y)}
38 | }
39 |
40 | def to_operator({:l, [x, y]}),
41 | do: %L{
42 | coords: {to_mudbrick(x), to_mudbrick(y)}
43 | }
44 |
45 | def to_operator({:RG, [r, g, b]}),
46 | do: %Rg{
47 | stroking: true,
48 | r: to_mudbrick(r),
49 | g: to_mudbrick(g),
50 | b: to_mudbrick(b)
51 | }
52 |
53 | def to_operator({:Do, [index]}), do: %Do{image_identifier: :"I#{index}"}
54 | def to_operator({:Td, [x, y]}), do: %Td{tx: to_mudbrick(x), ty: to_mudbrick(y)}
55 | def to_operator({:w, number}), do: %W{width: to_mudbrick(number)}
56 | def to_operator({:Tf, [index, size]}), do: %Tf{font_identifier: :"F#{index}", size: size}
57 | def to_operator({:TL, leading}), do: %TL{leading: to_mudbrick(leading)}
58 |
59 | def to_operator({:rg, components}),
60 | do: struct!(Rg, Enum.zip([:r, :g, :b], Enum.map(components, &to_mudbrick/1)))
61 |
62 | def to_operator({:TJ, glyphs_and_offsets}) do
63 | contains_kerns? = Enum.any?(glyphs_and_offsets, &match?({:offset, _}, &1))
64 |
65 | kerned_text =
66 | Enum.reduce(glyphs_and_offsets, [], fn
67 | {:glyph_id, id}, acc ->
68 | [id | acc]
69 |
70 | {:offset, {:integer, offset}}, [last_glyph | acc] ->
71 | [{last_glyph, offset |> Enum.join() |> String.to_integer()} | acc]
72 | end)
73 |
74 | %TJ{
75 | auto_kern: contains_kerns?,
76 | kerned_text: Enum.reverse(kerned_text)
77 | }
78 | end
79 |
80 | def to_operator({:Q, []}), do: %QPop{}
81 | def to_operator({:q, []}), do: %QPush{}
82 |
83 | def to_operator({op, []}),
84 | do: struct!(Module.safe_concat([Mudbrick.ContentStream, op]), [])
85 |
86 | def to_mudbrick([{block_type, _operations} | _rest] = input)
87 | when block_type in [:text_block, :graphics_block] do
88 | mudbrick_operations =
89 | Enum.flat_map(input, fn {_block_type, operations} ->
90 | Enum.map(operations, &to_operator/1)
91 | end)
92 |
93 | %Mudbrick.ContentStream{
94 | page: nil,
95 | operations: Enum.reverse(mudbrick_operations)
96 | }
97 | end
98 |
99 | def to_mudbrick(x) when is_tuple(x), do: to_mudbrick([x])
100 | def to_mudbrick(array: a), do: Enum.map(a, &to_mudbrick/1)
101 | def to_mudbrick(boolean: b), do: b
102 | def to_mudbrick(integer: [n]), do: String.to_integer(n)
103 | def to_mudbrick(integer: ["-", n]), do: -String.to_integer(n)
104 | def to_mudbrick(real: [n, ".", d]), do: String.to_float("#{n}.#{d}")
105 | def to_mudbrick(real: ["-" | rest]), do: -to_mudbrick(real: rest)
106 | def to_mudbrick(string: [s]), do: s
107 | def to_mudbrick(string: []), do: ""
108 | def to_mudbrick(name: s), do: String.to_atom(s)
109 |
110 | def to_mudbrick(pair: [k, v]) do
111 | {to_mudbrick(k), to_mudbrick(v)}
112 | end
113 |
114 | def to_mudbrick(dictionary: []), do: %{}
115 |
116 | def to_mudbrick(dictionary: pairs) do
117 | for {:pair, [k, v]} <- pairs, into: %{} do
118 | {to_mudbrick(k), to_mudbrick(v)}
119 | end
120 | end
121 |
122 | def to_mudbrick([]), do: []
123 |
124 | def to_mudbrick([[{:indirect_object, _} | _rest] = unwrapped]) do
125 | to_mudbrick(unwrapped)
126 | end
127 |
128 | def to_mudbrick([
129 | {:indirect_object,
130 | [
131 | ref_number,
132 | _version,
133 | {:dictionary, pairs}
134 | ]}
135 | | rest
136 | ]) do
137 | [
138 | Indirect.Ref.new(ref_number)
139 | |> Indirect.Object.new(
140 | pairs
141 | |> Enum.map(&to_mudbrick/1)
142 | |> Enum.into(%{})
143 | )
144 | | to_mudbrick(rest)
145 | ]
146 | end
147 |
148 | def to_mudbrick([
149 | {:indirect_object,
150 | [
151 | ref_number,
152 | _version,
153 | {:dictionary, pairs},
154 | "stream",
155 | stream
156 | ]}
157 | | rest
158 | ]) do
159 | additional_entries =
160 | pairs
161 | |> Enum.map(&to_mudbrick/1)
162 | |> Map.new()
163 | |> Map.drop([:Length])
164 |
165 | [
166 | Indirect.Ref.new(ref_number)
167 | |> Indirect.Object.new(
168 | Mudbrick.Stream.new(
169 | compress: additional_entries[:Filter] == [:FlateDecode],
170 | data: stream,
171 | additional_entries: additional_entries
172 | )
173 | )
174 | | to_mudbrick(rest)
175 | ]
176 | end
177 |
178 | def to_mudbrick([
179 | {:indirect_reference,
180 | [
181 | ref_number,
182 | _version,
183 | "R"
184 | ]}
185 | | _rest
186 | ]) do
187 | [
188 | ref_number
189 | |> String.to_integer()
190 | |> Indirect.Ref.new()
191 | ]
192 | end
193 | end
194 |
--------------------------------------------------------------------------------
/lib/mudbrick/parser/helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Parser.Helpers do
2 | @moduledoc false
3 |
4 | import NimbleParsec
5 |
6 | def eol, do: string("\n")
7 | def whitespace, do: ascii_string([?\n, ?\s], min: 1)
8 |
9 | def version do
10 | ignore(string("%PDF-"))
11 | |> integer(1)
12 | |> ignore(string("."))
13 | |> integer(1)
14 | |> ignore(eol())
15 | |> tag(:version)
16 | end
17 |
18 | def name do
19 | ignore(string("/"))
20 | |> utf8_string(
21 | [
22 | not: ?\s,
23 | not: ?\n,
24 | not: ?],
25 | not: ?[,
26 | not: ?/,
27 | not: ?<,
28 | not: ?>
29 | ],
30 | min: 1
31 | )
32 | |> unwrap_and_tag(:name)
33 | end
34 |
35 | def non_negative_integer do
36 | ascii_string([?0..?9], min: 1)
37 | end
38 |
39 | def negative_integer do
40 | string("-")
41 | |> concat(non_negative_integer())
42 | end
43 |
44 | def indirect_reference do
45 | non_negative_integer()
46 | |> ignore(whitespace())
47 | |> concat(non_negative_integer())
48 | |> ignore(whitespace())
49 | |> string("R")
50 | |> tag(:indirect_reference)
51 | end
52 |
53 | def integer do
54 | choice([
55 | non_negative_integer(),
56 | negative_integer()
57 | ])
58 | |> tag(:integer)
59 | end
60 |
61 | def real do
62 | choice([
63 | non_negative_integer(),
64 | negative_integer()
65 | ])
66 | |> string(".")
67 | |> optional(non_negative_integer())
68 | |> tag(:real)
69 | end
70 |
71 | def number do
72 | choice([real(), integer()])
73 | end
74 |
75 | def string do
76 | ignore(string("("))
77 | |> optional(ascii_string([not: ?(, not: ?)], min: 1))
78 | |> ignore(string(")"))
79 | |> tag(:string)
80 | end
81 |
82 | def boolean do
83 | choice([
84 | string("true") |> replace(true),
85 | string("false") |> replace(false)
86 | ])
87 | |> unwrap_and_tag(:boolean)
88 | end
89 |
90 | def xref do
91 | ignore(string("xref"))
92 | |> ignore(eol())
93 | end
94 |
95 | def cm do
96 | six_number_operation("cm")
97 | |> tag(:cm)
98 | end
99 |
100 | def do_paint do
101 | ignore(string("/I"))
102 | |> concat(non_negative_integer())
103 | |> ignore(whitespace())
104 | |> ignore(string("Do"))
105 | |> tag(:Do)
106 | end
107 |
108 | def tf do
109 | ignore(string("/F"))
110 | |> concat(non_negative_integer())
111 | |> ignore(whitespace())
112 | |> concat(non_negative_integer())
113 | |> ignore(whitespace())
114 | |> ignore(string("Tf"))
115 | |> tag(:Tf)
116 | end
117 |
118 | def tl do
119 | real()
120 | |> ignore(string(" TL"))
121 | |> tag(:TL)
122 | end
123 |
124 | def rg_stroking do
125 | three_number_operation("RG")
126 | |> tag(:RG)
127 | end
128 |
129 | def rg_non_stroking do
130 | three_number_operation("rg")
131 | |> tag(:rg)
132 | end
133 |
134 | def m do
135 | two_number_operation("m")
136 | |> tag(:m)
137 | end
138 |
139 | def w do
140 | one_number_operation("w")
141 | |> tag(:w)
142 | end
143 |
144 | def glyph_id_hex do
145 | ignore(string("<"))
146 | |> ascii_string([?A..?Z, ?0..?9], min: 1)
147 | |> ignore(string(">"))
148 | |> unwrap_and_tag(:glyph_id)
149 | end
150 |
151 | def tj do
152 | ignore(string("["))
153 | |> ignore(whitespace())
154 | |> repeat(
155 | choice([
156 | glyph_id_hex(),
157 | integer() |> unwrap_and_tag(:offset)
158 | ])
159 | |> ignore(whitespace())
160 | )
161 | |> ignore(string("]"))
162 | |> ignore(whitespace())
163 | |> ignore(string("TJ"))
164 | |> tag(:TJ)
165 | end
166 |
167 | def td do
168 | two_number_operation("Td")
169 | |> tag(:Td)
170 | end
171 |
172 | def l do
173 | two_number_operation("l")
174 | |> tag(:l)
175 | end
176 |
177 | def q_push do
178 | ignore(string("q")) |> tag(:q)
179 | end
180 |
181 | def re do
182 | four_number_operation("re")
183 | |> tag(:re)
184 | end
185 |
186 | def s do
187 | ignore(string("S")) |> tag(:S)
188 | end
189 |
190 | def t_star do
191 | ignore(string("T*")) |> tag(:TStar)
192 | end
193 |
194 | def q_pop do
195 | ignore(string("Q")) |> tag(:Q)
196 | end
197 |
198 | def content_blocks do
199 | repeat(
200 | choice([
201 | text_block(),
202 | graphics_block()
203 | ])
204 | |> ignore(optional(whitespace()))
205 | )
206 | end
207 |
208 | def graphics_block do
209 | q_push()
210 | |> ignore(whitespace())
211 | |> repeat(
212 | choice([
213 | cm(),
214 | do_paint(),
215 | l(),
216 | m(),
217 | re(),
218 | rg_non_stroking(),
219 | rg_stroking(),
220 | s(),
221 | w()
222 | ])
223 | |> ignore(whitespace())
224 | )
225 | |> concat(q_pop())
226 | |> tag(:graphics_block)
227 | end
228 |
229 | def text_block do
230 | tag(ignore(string("BT")), :BT)
231 | |> ignore(whitespace())
232 | |> repeat(
233 | choice([
234 | l(),
235 | m(),
236 | q_pop(),
237 | q_push(),
238 | rg_non_stroking(),
239 | rg_stroking(),
240 | s(),
241 | td(),
242 | tf(),
243 | tj(),
244 | tl(),
245 | t_star(),
246 | w()
247 | ])
248 | |> ignore(whitespace())
249 | )
250 | |> tag(ignore(string("ET")), :ET)
251 | |> tag(:text_block)
252 | end
253 |
254 | defp one_number_operation(operator) do
255 | number()
256 | |> ignore(whitespace())
257 | |> ignore(string(operator))
258 | end
259 |
260 | defp two_number_operation(operator) do
261 | number()
262 | |> ignore(whitespace())
263 | |> concat(number())
264 | |> ignore(whitespace())
265 | |> ignore(string(operator))
266 | end
267 |
268 | defp three_number_operation(operator) do
269 | number()
270 | |> ignore(whitespace())
271 | |> concat(number())
272 | |> ignore(whitespace())
273 | |> concat(number())
274 | |> ignore(whitespace())
275 | |> ignore(string(operator))
276 | end
277 |
278 | defp four_number_operation(operator) do
279 | number()
280 | |> ignore(whitespace())
281 | |> concat(number())
282 | |> ignore(whitespace())
283 | |> concat(number())
284 | |> ignore(whitespace())
285 | |> concat(number())
286 | |> ignore(whitespace())
287 | |> ignore(string(operator))
288 | end
289 |
290 | defp six_number_operation(operator) do
291 | number()
292 | |> ignore(whitespace())
293 | |> concat(number())
294 | |> ignore(whitespace())
295 | |> concat(number())
296 | |> ignore(whitespace())
297 | |> concat(number())
298 | |> ignore(whitespace())
299 | |> concat(number())
300 | |> ignore(whitespace())
301 | |> concat(number())
302 | |> ignore(whitespace())
303 | |> ignore(string(operator))
304 | end
305 | end
306 |
--------------------------------------------------------------------------------
/lib/mudbrick/path.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Path do
2 | defmodule Rectangle do
3 | @type option ::
4 | {:lower_left, Mudbrick.coords()}
5 | | {:dimensions, Mudbrick.coords()}
6 | | {:line_width, number()}
7 | | {:colour, Mudbrick.colour()}
8 |
9 | @type options :: [option()]
10 |
11 | @type t :: %__MODULE__{
12 | lower_left: Mudbrick.coords(),
13 | dimensions: Mudbrick.coords(),
14 | line_width: number(),
15 | colour: Mudbrick.colour()
16 | }
17 |
18 | @enforce_keys [:lower_left, :dimensions]
19 | defstruct lower_left: nil,
20 | dimensions: nil,
21 | line_width: 1,
22 | colour: {0, 0, 0}
23 |
24 | @doc false
25 | @spec new(options()) :: t()
26 | def new(opts) do
27 | struct!(__MODULE__, opts)
28 | end
29 | end
30 |
31 | defmodule Move do
32 | @type option :: {:to, Mudbrick.coords()}
33 |
34 | @type options :: [option()]
35 |
36 | @type t :: %__MODULE__{
37 | to: Mudbrick.coords()
38 | }
39 |
40 | @enforce_keys [:to]
41 | defstruct to: nil
42 |
43 | @doc false
44 | @spec new(options()) :: t()
45 | def new(opts) do
46 | struct!(__MODULE__, opts)
47 | end
48 | end
49 |
50 | defmodule Line do
51 | @type option ::
52 | {:to, Mudbrick.coords()}
53 | | {:width, number()}
54 | | {:colour, Mudbrick.colour()}
55 |
56 | @type options :: [option()]
57 |
58 | @type t :: %__MODULE__{
59 | to: Mudbrick.coords(),
60 | width: number(),
61 | colour: Mudbrick.colour()
62 | }
63 |
64 | @enforce_keys [:to]
65 | defstruct to: nil,
66 | width: 1,
67 | colour: {0, 0, 0}
68 |
69 | @doc false
70 | @spec new(options()) :: t()
71 | def new(opts) do
72 | struct!(__MODULE__, opts)
73 | end
74 | end
75 |
76 | @type sub_path ::
77 | Move.t()
78 | | Rectangle.t()
79 | | Line.t()
80 |
81 | @type t :: %__MODULE__{
82 | sub_paths: [sub_path()]
83 | }
84 |
85 | defstruct sub_paths: []
86 |
87 | @doc false
88 | @spec new :: t()
89 | def new do
90 | struct!(__MODULE__, [])
91 | end
92 |
93 | @spec move(t(), Move.options()) :: t()
94 | def move(path, opts) do
95 | add(path, Move.new(opts))
96 | end
97 |
98 | @spec line(t(), Line.options()) :: t()
99 | def line(path, opts) do
100 | add(path, Line.new(opts))
101 | end
102 |
103 | @spec rectangle(t(), Rectangle.options()) :: t()
104 | def rectangle(path, opts) do
105 | add(path, Rectangle.new(opts))
106 | end
107 |
108 | defp add(path, sub_path) do
109 | %{path | sub_paths: [sub_path | path.sub_paths]}
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/lib/mudbrick/path/output.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Path.Output do
2 | @moduledoc false
3 |
4 | defstruct operations: []
5 |
6 | alias Mudbrick.ContentStream.{
7 | L,
8 | M,
9 | QPop,
10 | QPush,
11 | Re,
12 | Rg,
13 | S,
14 | W
15 | }
16 |
17 | def to_iodata(%Mudbrick.Path{sub_paths: []}) do
18 | %__MODULE__{}
19 | end
20 |
21 | def to_iodata(%Mudbrick.Path{} = path) do
22 | %__MODULE__{}
23 | |> add(%QPush{})
24 | |> then(fn output ->
25 | for sub_path <- Enum.reverse(path.sub_paths), reduce: output do
26 | acc ->
27 | case sub_path do
28 | %Mudbrick.Path.Move{} = move ->
29 | acc
30 | |> add(%M{coords: move.to})
31 |
32 | %Mudbrick.Path.Line{} = line ->
33 | {r, g, b} = line.colour
34 |
35 | acc
36 | |> add(Rg.new(stroking: true, r: r, g: g, b: b))
37 | |> add(%W{width: line.width})
38 | |> add(%L{coords: line.to})
39 | |> add(%S{})
40 |
41 | %Mudbrick.Path.Rectangle{} = rect ->
42 | {r, g, b} = rect.colour
43 |
44 | acc
45 | |> add(Rg.new(stroking: true, r: r, g: g, b: b))
46 | |> add(%W{width: rect.line_width})
47 | |> add(%Re{
48 | lower_left: rect.lower_left,
49 | dimensions: rect.dimensions
50 | })
51 | |> add(%S{})
52 | end
53 | end
54 | end)
55 | |> add(%QPop{})
56 | end
57 |
58 | def add(%__MODULE__{} = output, op) do
59 | %{output | operations: [op | output.operations]}
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/mudbrick/predicates.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Predicates do
2 | @moduledoc """
3 | Useful for testing PDF documents.
4 |
5 | While these predicates do check the PDF in a black-box way, it's not expected
6 | that they will work on PDFs not generated with Mudbrick.
7 | """
8 |
9 | @doc """
10 | Checks for presence of `text` in the `pdf` `iodata`. Searches compressed and uncompressed data.
11 |
12 | This arity only works with text that can be found in literal form inside a stream, compressed or uncompressed,
13 | """
14 | @spec has_text?(pdf :: iodata(), text :: binary()) :: boolean()
15 | def has_text?(pdf, text) do
16 | binary = IO.iodata_to_binary(pdf)
17 | streams = extract_streams(binary)
18 | Enum.any?(streams, &String.contains?(&1, text))
19 | end
20 |
21 | @doc """
22 | Checks for presence of `text` in the `pdf` `iodata`. Searches compressed and uncompressed data.
23 |
24 | This arity requires you to pass the raw font data in which the text is
25 | expected to be written. The text must be present in TJ operator format, which
26 | raw hexadecimal form corresponding to the font's glyph IDs, interspersed with
27 | optional kerning offsets.
28 |
29 | The [OpenType](https://hexdocs.pm/opentype) library is used to find font
30 | features, such as ligatures, which are expected to have been used in the PDF.
31 |
32 | ## Options
33 |
34 | - `:in_font` - raw font data in which the text is expected. Required.
35 |
36 | ## Example: with compression
37 |
38 | iex> import Mudbrick.TestHelper
39 | ...> import Mudbrick.Predicates
40 | ...> import Mudbrick
41 | ...> font = bodoni_regular()
42 | ...> raw_pdf =
43 | ...> new(compress: true, fonts: %{default: font})
44 | ...> |> page()
45 | ...> |> text(
46 | ...> "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWhello, CO₂!WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
47 | ...> font_size: 100
48 | ...> )
49 | ...> |> render()
50 | ...> |> IO.iodata_to_binary()
51 | ...> {has_text?(raw_pdf, "hello, CO₂!", in_font: font), has_text?(raw_pdf, "good morning!", in_font: font)}
52 | {true, false}
53 |
54 | ## Example: without compression
55 |
56 | iex> import Mudbrick.TestHelper
57 | ...> import Mudbrick.Predicates
58 | ...> import Mudbrick
59 | ...> font = bodoni_regular()
60 | ...> raw_pdf =
61 | ...> new(compress: false, fonts: %{default: font})
62 | ...> |> page()
63 | ...> |> text(
64 | ...> "Hello, world!",
65 | ...> font_size: 100
66 | ...> )
67 | ...> |> render()
68 | ...> |> IO.iodata_to_binary()
69 | ...> {has_text?(raw_pdf, "Hello, world!", in_font: font), has_text?(raw_pdf, "Good morning!", in_font: font)}
70 | {true, false}
71 | """
72 | @spec has_text?(pdf :: iodata(), text :: binary(), opts :: list()) :: boolean()
73 | def has_text?(pdf, text, opts) do
74 | font = Keyword.fetch!(opts, :in_font)
75 | parsed_font = OpenType.new() |> OpenType.parse(font)
76 |
77 | mudbrick_font = %Mudbrick.Font{
78 | name: nil,
79 | resource_identifier: nil,
80 | type: nil,
81 | parsed: parsed_font
82 | }
83 |
84 | pattern_source =
85 | Mudbrick.Font.kerned(mudbrick_font, text)
86 | |> Enum.reduce("", &append_glyph_id/2)
87 |
88 | pattern = Regex.compile!(pattern_source)
89 |
90 | pdf
91 | |> extract_streams()
92 | |> Enum.any?(&(&1 =~ pattern))
93 | end
94 |
95 | defp extract_streams(pdf) do
96 | binary = IO.iodata_to_binary(pdf)
97 |
98 | ~r"<<(.*?)>>\nstream\n(.*?)endstream"s
99 | |> Regex.scan(binary, capture: :all_but_first)
100 | |> Enum.map(fn
101 | [dictionary, content] ->
102 | if String.contains?(dictionary, "FlateDecode") do
103 | content |> Mudbrick.decompress() |> IO.iodata_to_binary()
104 | else
105 | content
106 | end
107 | end)
108 | end
109 |
110 | defp append_glyph_id({glyph_id, _kerning}, acc) do
111 | append_glyph_id(glyph_id, acc)
112 | end
113 |
114 | defp append_glyph_id(glyph_id, acc) do
115 | "#{acc}<#{glyph_id}>[ \\d]+"
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/lib/mudbrick/serialisation/document.ex:
--------------------------------------------------------------------------------
1 | defimpl Mudbrick.Object, for: Mudbrick.Document do
2 | @initial_generation "00000"
3 | @free_entries_first_generation "65535"
4 |
5 | alias Mudbrick.Document
6 | alias Mudbrick.Object
7 |
8 | def to_iodata(%Document{objects: raw_objects} = doc) do
9 | header = "%PDF-2.0\n%����"
10 | objects = raw_objects |> Enum.reverse() |> Enum.map(&Object.to_iodata/1)
11 | sections = [header | objects]
12 |
13 | trailer =
14 | Object.to_iodata(%{
15 | Size: length(objects) + 1,
16 | Root: Document.catalog(doc).ref
17 | })
18 |
19 | [
20 | Enum.intersperse(sections, ?\n),
21 | "\nxref\n",
22 | ["0 ", Kernel.to_string(length(objects) + 1), "\n"],
23 | offsets(sections),
24 | "\ntrailer\n",
25 | trailer,
26 | "\nstartxref\n",
27 | offset(sections),
28 | "\n%%EOF"
29 | ]
30 | end
31 |
32 | defp offsets(sections) do
33 | {_, retval} =
34 | for section <- sections, reduce: {[], []} do
35 | {[], []} ->
36 | {
37 | [section],
38 | [padded_offset([]), " ", @free_entries_first_generation, " f "]
39 | }
40 |
41 | {past_sections, iolist} ->
42 | {
43 | [section | past_sections],
44 | [iolist, "\n", padded_offset(past_sections), " ", @initial_generation, " n "]
45 | }
46 | end
47 |
48 | retval
49 | end
50 |
51 | defp padded_offset(strings) do
52 | strings
53 | |> offset()
54 | |> String.pad_leading(10, "0")
55 | end
56 |
57 | defp offset(strings) do
58 | strings
59 | |> Enum.map(&IO.iodata_length([&1, "\n"]))
60 | |> Enum.sum()
61 | |> Kernel.to_string()
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/mudbrick/serialisation/object.ex:
--------------------------------------------------------------------------------
1 | defprotocol Mudbrick.Object do
2 | @moduledoc false
3 |
4 | @fallback_to_any true
5 | @spec to_iodata(value :: any()) :: iodata()
6 | def to_iodata(value)
7 | end
8 |
9 | defimpl Mudbrick.Object, for: Any do
10 | def to_iodata(term) do
11 | [to_string(term)]
12 | end
13 | end
14 |
15 | defimpl Mudbrick.Object, for: Atom do
16 | def to_iodata(a) when a in [true, false] do
17 | [to_string(a)]
18 | end
19 |
20 | def to_iodata(nil) do
21 | ["/nil"]
22 | end
23 |
24 | def to_iodata(name) do
25 | [?/, escape_chars(name)]
26 | end
27 |
28 | defp escape_chars(name) do
29 | name
30 | |> to_string()
31 | |> String.to_charlist()
32 | |> Enum.flat_map(&escape_char/1)
33 | end
34 |
35 | defp escape_char(char) when char not in ?!..?~ do
36 | "##{Base.encode16(to_string([char]))}" |> String.to_charlist()
37 | end
38 |
39 | defp escape_char(char) do
40 | [char]
41 | end
42 | end
43 |
44 | defimpl Mudbrick.Object, for: BitString do
45 | @escapees %{
46 | ?\n => "\\n",
47 | ?\r => "\\r",
48 | ?\t => "\\t",
49 | ?\b => "\\b",
50 | ?\f => "\\f",
51 | ?( => "\\(",
52 | ?) => "\\)",
53 | ?\\ => "\\",
54 | 0xDDD => "\\ddd"
55 | }
56 |
57 | def to_iodata(s) do
58 | [?(, escape_chars(s), ?)]
59 | end
60 |
61 | defp escape_chars(name) do
62 | name
63 | |> String.to_charlist()
64 | |> Enum.flat_map(&escape_char/1)
65 | |> Kernel.to_string()
66 | end
67 |
68 | defp escape_char(char) do
69 | [Map.get(@escapees, char, char)]
70 | end
71 | end
72 |
73 | defimpl Mudbrick.Object, for: List do
74 | def to_iodata(list) do
75 | [?[, Mudbrick.join(list), ?]]
76 | end
77 | end
78 |
79 | defimpl Mudbrick.Object, for: Map do
80 | def to_iodata(kvs) do
81 | ["<<", pairs(kvs), "\n>>"]
82 | end
83 |
84 | defp pairs(kvs) do
85 | kvs
86 | |> Enum.sort(fn
87 | {:Type, _v1}, _ -> true
88 | _pair, {:Type, _v2} -> false
89 | {:Subtype, _v1}, _pair -> true
90 | _pair, {:Subtype, _v2} -> false
91 | {k1, _v1}, {k2, _v2} -> k1 <= k2
92 | end)
93 | |> format_kvs()
94 | end
95 |
96 | defp format_kvs(kvs) do
97 | Enum.map_join(kvs, "\n ", &Mudbrick.join/1)
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/lib/mudbrick/stream.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.Stream do
2 | @moduledoc false
3 |
4 | defstruct compress: false,
5 | data: nil,
6 | additional_entries: %{},
7 | length: nil,
8 | filters: []
9 |
10 | def new(opts) do
11 | opts =
12 | case decide_compression(opts) do
13 | {:ok, data} ->
14 | Keyword.merge(
15 | opts,
16 | data: data,
17 | length: IO.iodata_length(data),
18 | filters: [:FlateDecode]
19 | )
20 |
21 | :error ->
22 | opts
23 | end
24 |
25 | struct!(__MODULE__, Keyword.put(opts, :length, IO.iodata_length(opts[:data])))
26 | end
27 |
28 | defp decide_compression(opts) do
29 | if opts[:compress] do
30 | uncompressed = opts[:data]
31 | compressed = Mudbrick.compress(uncompressed)
32 |
33 | if IO.iodata_length(compressed) < IO.iodata_length(uncompressed) do
34 | {:ok, compressed}
35 | else
36 | :error
37 | end
38 | else
39 | :error
40 | end
41 | end
42 |
43 | defimpl Mudbrick.Object do
44 | def to_iodata(stream) do
45 | [
46 | Mudbrick.Object.to_iodata(
47 | %{Length: stream.length}
48 | |> Map.merge(stream.additional_entries)
49 | |> Map.merge(
50 | if Enum.empty?(stream.filters) do
51 | %{}
52 | else
53 | %{Filter: stream.filters}
54 | end
55 | )
56 | ),
57 | "\nstream\n",
58 | if(stream.data == [""], do: [], else: [stream.data, "\n"]),
59 | "endstream"
60 | ]
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/mudbrick/text_block.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.TextBlock do
2 | @type alignment :: :left | :right
3 |
4 | @type underline_option ::
5 | {:width, number()}
6 | | {:colour, Mudbrick.colour()}
7 | @type underline_options :: [underline_option()]
8 |
9 | @type option ::
10 | {:align, alignment()}
11 | | {:auto_kern, boolean()}
12 | | {:colour, Mudbrick.colour()}
13 | | {:font, atom()}
14 | | {:font_size, number()}
15 | | {:leading, number()}
16 | | {:position, Mudbrick.coords()}
17 |
18 | @type options :: [option()]
19 |
20 | @type part_option ::
21 | {:colour, Mudbrick.colour()}
22 | | {:font, atom()}
23 | | {:font_size, number()}
24 | | {:leading, number()}
25 | | {:underline, underline_options()}
26 |
27 | @type part_options :: [part_option()]
28 |
29 | @type write_tuple :: {String.t(), part_options()}
30 | @type write_part :: String.t() | write_tuple()
31 |
32 | @type write ::
33 | write_part()
34 | | list(write_part())
35 |
36 | @type t :: %__MODULE__{
37 | align: alignment(),
38 | colour: Mudbrick.colour(),
39 | font: Mudbrick.Font.t(),
40 | font_size: number(),
41 | lines: list(),
42 | position: Mudbrick.coords(),
43 | leading: number()
44 | }
45 |
46 | defstruct align: :left,
47 | auto_kern: true,
48 | colour: {0, 0, 0},
49 | font: nil,
50 | font_size: 12,
51 | leading: nil,
52 | lines: [],
53 | position: {0, 0}
54 |
55 | alias Mudbrick.TextBlock.Line
56 |
57 | @doc false
58 | @spec new(options()) :: t()
59 | def new(opts \\ []) do
60 | block = struct!(__MODULE__, opts)
61 |
62 | Map.update!(block, :leading, fn
63 | nil ->
64 | block.font_size * 1.2
65 |
66 | leading ->
67 | leading
68 | end)
69 | end
70 |
71 | @doc false
72 | @spec write(t(), String.t(), options()) :: t()
73 | def write(tb, text, opts \\ []) do
74 | tb
75 | |> write_lines(text, opts)
76 | |> assign_offsets()
77 | end
78 |
79 | defp assign_offsets(tb) do
80 | {_, lines} =
81 | for line <- Enum.reverse(tb.lines), reduce: {0.0, []} do
82 | {y, lines} ->
83 | {y - line.leading,
84 | [
85 | %{
86 | line
87 | | parts:
88 | (
89 | {_, parts} =
90 | for part <- Enum.reverse(line.parts), reduce: {0.0, []} do
91 | {x, parts} ->
92 | width = Line.Part.width(part)
93 | {x + width, [%{part | left_offset: {x, y}} | parts]}
94 | end
95 |
96 | parts
97 | )
98 | }
99 | | lines
100 | ]}
101 | end
102 |
103 | %{tb | lines: lines}
104 | end
105 |
106 | defp write_lines(tb, text, chosen_opts) do
107 | line_texts = String.split(text, "\n")
108 |
109 | text_block_opts = [
110 | auto_kern: tb.auto_kern,
111 | colour: tb.colour,
112 | font_size: tb.font_size,
113 | font: tb.font,
114 | leading: tb.leading
115 | ]
116 |
117 | merged_opts =
118 | Keyword.merge(
119 | text_block_opts,
120 | chosen_opts,
121 | &prefer_lhs_over_nil/3
122 | )
123 |
124 | Map.update!(tb, :lines, fn
125 | [] ->
126 | add_texts([], line_texts, merged_opts, text_block_opts)
127 |
128 | existing_lines ->
129 | case line_texts do
130 | # \n at beginning of new line
131 | ["" | new_line_texts] ->
132 | existing_lines
133 | |> add_texts(new_line_texts, merged_opts, text_block_opts)
134 |
135 | # didn't start with \n, so first part belongs to previous line
136 | [first_new_line_text | new_line_texts] ->
137 | existing_lines
138 | # Update previous line with chosen opts, to allow logic around
139 | # choices.
140 | |> update_previous_line(first_new_line_text, merged_opts, chosen_opts)
141 | |> add_texts(new_line_texts, merged_opts, text_block_opts)
142 | end
143 | end)
144 | end
145 |
146 | defp update_previous_line(
147 | [previous_line | existing_lines],
148 | first_new_line_text,
149 | merged_opts,
150 | opts
151 | ) do
152 | [
153 | Line.append(previous_line, first_new_line_text, merged_opts, opts)
154 | | existing_lines
155 | ]
156 | end
157 |
158 | defp add_texts(existing_lines, new_line_texts, opts, opts_for_empty_lines) do
159 | for text <- new_line_texts, reduce: existing_lines do
160 | acc ->
161 | if text == "" do
162 | [Line.wrap(text, opts_for_empty_lines) | acc]
163 | else
164 | [Line.wrap(text, opts) | acc]
165 | end
166 | end
167 | end
168 |
169 | defp prefer_lhs_over_nil(_key, lhs, nil) do
170 | lhs
171 | end
172 |
173 | defp prefer_lhs_over_nil(_key, _lhs, rhs) do
174 | rhs
175 | end
176 | end
177 |
--------------------------------------------------------------------------------
/lib/mudbrick/text_block/line.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.TextBlock.Line do
2 | @moduledoc false
3 |
4 | @enforce_keys [:leading, :parts]
5 | defstruct leading: nil, parts: []
6 |
7 | defmodule Part do
8 | @moduledoc false
9 |
10 | @enforce_keys [
11 | :auto_kern,
12 | :colour,
13 | :font,
14 | :font_size,
15 | :text
16 | ]
17 | defstruct auto_kern: true,
18 | colour: {0, 0, 0},
19 | font: nil,
20 | font_size: nil,
21 | left_offset: nil,
22 | text: "",
23 | underline: nil
24 |
25 | def width(%__MODULE__{font: nil}) do
26 | raise Mudbrick.Font.MustBeChosen
27 | end
28 |
29 | def width(part) do
30 | Mudbrick.Font.width(
31 | part.font,
32 | part.font_size,
33 | part.text,
34 | auto_kern: part.auto_kern
35 | )
36 | end
37 |
38 | def new(text, opts) when text != "" do
39 | struct(__MODULE__, Keyword.put_new(opts, :text, text))
40 | end
41 | end
42 |
43 | def wrap("", opts) do
44 | struct(__MODULE__, opts)
45 | end
46 |
47 | def wrap(text, opts) do
48 | struct(__MODULE__, Keyword.put(opts, :parts, [Part.new(text, opts)]))
49 | end
50 |
51 | def append(line, text, merged_opts, chosen_opts) do
52 | line = Map.update!(line, :parts, &[Part.new(text, merged_opts) | &1])
53 |
54 | if chosen_opts[:leading] do
55 | Map.put(line, :leading, chosen_opts[:leading])
56 | else
57 | line
58 | end
59 | end
60 |
61 | def width(line) do
62 | for part <- line.parts, reduce: 0.0 do
63 | acc -> acc + Part.width(part)
64 | end
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/lib/mudbrick/text_block/output.ex:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.TextBlock.Output do
2 | @moduledoc false
3 |
4 | defstruct position: nil,
5 | font: nil,
6 | font_size: nil,
7 | operations: [],
8 | drawings: []
9 |
10 | alias Mudbrick.ContentStream.{BT, ET}
11 | alias Mudbrick.ContentStream.Rg
12 | alias Mudbrick.ContentStream.Td
13 | alias Mudbrick.ContentStream.Tf
14 | alias Mudbrick.ContentStream.{TJ, TStar}
15 | alias Mudbrick.ContentStream.TL
16 | alias Mudbrick.Path
17 | alias Mudbrick.TextBlock.Line
18 |
19 | def to_iodata(
20 | %Mudbrick.TextBlock{
21 | font: font,
22 | font_size: font_size,
23 | position: position
24 | } = tb
25 | ) do
26 | tl = %TL{leading: tb.leading}
27 | tf = %Tf{font_identifier: font.resource_identifier, size: font_size}
28 |
29 | %__MODULE__{position: position, font: font, font_size: font_size}
30 | |> end_block()
31 | |> reduce_lines(
32 | tb.lines,
33 | case tb.align do
34 | :left -> fn _ -> 0 end
35 | :right -> &Line.width/1
36 | :centre -> fn line -> Line.width(line) / 2 end
37 | end
38 | )
39 | |> td(position)
40 | |> add(tl)
41 | |> add(tf)
42 | |> start_block()
43 | |> drawings()
44 | |> deduplicate(tl)
45 | |> deduplicate(tf)
46 | |> Map.update!(:operations, &Enum.reverse/1)
47 | end
48 |
49 | defp add_part(output, part) do
50 | output
51 | |> with_font(
52 | struct!(TJ,
53 | auto_kern: part.auto_kern,
54 | kerned_text: Mudbrick.Font.kerned(output.font, part.text)
55 | ),
56 | part
57 | )
58 | |> colour(part.colour)
59 | end
60 |
61 | defp add(%__MODULE__{} = output, op) do
62 | Map.update!(output, :operations, &[op | &1])
63 | end
64 |
65 | defp remove(output, operation) do
66 | Map.update!(output, :operations, &List.delete(&1, operation))
67 | end
68 |
69 | defp deduplicate(output, initial_operator) do
70 | Map.update!(output, :operations, fn ops ->
71 | ops
72 | |> deduplicate_update(initial_operator)
73 | |> Enum.reverse()
74 | end)
75 | end
76 |
77 | defp deduplicate_update(ops, initial_operator) do
78 | {_, ops} =
79 | List.foldl(ops, {initial_operator, []}, fn
80 | current_operator, {current_operator, acc} ->
81 | if current_operator in acc do
82 | {current_operator, acc}
83 | else
84 | {current_operator, [current_operator | acc]}
85 | end
86 |
87 | op, {current_operator, acc} ->
88 | if op.__struct__ == initial_operator.__struct__ do
89 | {op, [op | acc]}
90 | else
91 | {current_operator, [op | acc]}
92 | end
93 | end)
94 |
95 | ops
96 | end
97 |
98 | defp reduce_lines(output, [line], x_offsetter) do
99 | output
100 | |> leading(line)
101 | |> reset_offset(x_offsetter.(line))
102 | |> reduce_parts(line, :first_line, x_offsetter.(line))
103 | |> offset(x_offsetter.(line))
104 | end
105 |
106 | defp reduce_lines(output, [line | lines], x_offsetter) do
107 | output
108 | |> leading(line)
109 | |> reset_offset(x_offsetter.(line))
110 | |> reduce_parts(line, nil, x_offsetter.(line))
111 | |> offset(x_offsetter.(line))
112 | |> reduce_lines(lines, x_offsetter)
113 | end
114 |
115 | defp reduce_parts(output, %Line{parts: []}, :first_line, _x_offset) do
116 | output
117 | end
118 |
119 | defp reduce_parts(output, %Line{parts: [part]}, :first_line, x_offset) do
120 | output
121 | |> add_part(part)
122 | |> underline(part, x_offset)
123 | end
124 |
125 | defp reduce_parts(output, %Line{parts: []}, nil, _x_offset) do
126 | output
127 | |> add(%TStar{})
128 | end
129 |
130 | defp reduce_parts(output, %Line{parts: [part]}, nil, x_offset) do
131 | output
132 | |> add_part(part)
133 | |> add(%TStar{})
134 | |> underline(part, x_offset)
135 | end
136 |
137 | defp reduce_parts(
138 | output,
139 | %Line{parts: [part | parts]} = line,
140 | line_kind,
141 | x_offset
142 | ) do
143 | output
144 | |> add_part(part)
145 | |> underline(part, x_offset)
146 | |> reduce_parts(%{line | parts: parts}, line_kind, x_offset)
147 | end
148 |
149 | defp leading(output, line) do
150 | output
151 | |> add(%TL{leading: line.leading})
152 | end
153 |
154 | defp offset(output, offset) do
155 | td(output, {-offset, 0})
156 | end
157 |
158 | defp reset_offset(output, offset) do
159 | td(output, {offset, 0})
160 | end
161 |
162 | defp underline(output, %Line.Part{underline: nil}, _line_x_offset), do: output
163 |
164 | defp underline(output, part, line_x_offset) do
165 | Map.update!(output, :drawings, fn drawings ->
166 | [underline_path(output, part, line_x_offset) | drawings]
167 | end)
168 | end
169 |
170 | defp underline_path(output, part, line_x_offset) do
171 | {initial_x, initial_y} = output.position
172 | {offset_x, offset_y} = part.left_offset
173 |
174 | x = initial_x + offset_x - line_x_offset
175 | y = initial_y + offset_y - part.font_size / 10
176 |
177 | Path.new()
178 | |> Path.move(to: {x, y})
179 | |> Path.line(Keyword.put(part.underline, :to, {x + Line.Part.width(part), y}))
180 | |> Path.Output.to_iodata()
181 | end
182 |
183 | defp drawings(output) do
184 | Map.update!(output, :operations, fn ops ->
185 | for drawing <- output.drawings, reduce: ops do
186 | ops ->
187 | Enum.reverse(drawing.operations) ++ ops
188 | end
189 | end)
190 | end
191 |
192 | defp td(output, {0, 0}), do: output
193 | defp td(output, {x, y}), do: add(output, %Td{tx: x, ty: y})
194 |
195 | defp with_font(output, op, part) do
196 | output
197 | |> add(%Tf{font_identifier: output.font.resource_identifier, size: output.font_size})
198 | |> add(op)
199 | |> add(%Tf{font_identifier: part.font.resource_identifier, size: part.font_size})
200 | end
201 |
202 | defp colour(output, {r, g, b}) do
203 | new_colour = Rg.new(r: r, g: g, b: b)
204 | latest_colour = Enum.find(output.operations, &match?(%Rg{}, &1)) || %Rg{r: 0, g: 0, b: 0}
205 |
206 | if latest_colour == new_colour do
207 | remove(output, new_colour)
208 | else
209 | output
210 | end
211 | |> add(new_colour)
212 | end
213 |
214 | defp start_block(output) do
215 | add(output, %BT{})
216 | end
217 |
218 | defp end_block(output) do
219 | add(output, %ET{})
220 | end
221 | end
222 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.MixProject do
2 | use Mix.Project
3 |
4 | @scm_url "https://github.com/code-supply/mudbrick"
5 |
6 | def project do
7 | [
8 | app: :mudbrick,
9 | deps: deps(),
10 | description: "PDF-2.0 generator",
11 | dialyzer: dialyzer(),
12 | elixir: "~> 1.17",
13 | package: package(),
14 | start_permanent: Mix.env() == :prod,
15 | version: "0.9.0",
16 |
17 | # Docs
18 | source_url: @scm_url,
19 | docs: [
20 | main: "Mudbrick",
21 | extras: ["README.md"],
22 | assets: %{
23 | "examples" => "examples"
24 | }
25 | ]
26 | ]
27 | end
28 |
29 | # Run "mix help compile.app" to learn about applications.
30 | def application do
31 | [
32 | extra_applications: [:logger, :xmerl]
33 | ]
34 | end
35 |
36 | # Run "mix help deps" to learn about dependencies.
37 | defp deps do
38 | [
39 | {:credo, "~> 1.7.0", only: [:dev, :test]},
40 | {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
41 | {:ex_doc, "~> 0.21", only: [:dev], runtime: false},
42 | {:ex_image_info, "~> 0.2"},
43 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false},
44 | {:nimble_parsec, "~> 1.4", runtime: false},
45 | {:opentype, "~> 0.5"},
46 | {:stream_data, "~> 1.0", only: [:dev, :test]}
47 | ]
48 | end
49 |
50 | defp dialyzer do
51 | [
52 | plt_add_apps: [:nimble_parsec]
53 | ]
54 | end
55 |
56 | defp package do
57 | [
58 | links: %{"GitHub" => @scm_url},
59 | licenses: ["MIT"]
60 | ]
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
3 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"},
4 | "dialyxir": {:hex, :dialyxir, "1.4.4", "fb3ce8741edeaea59c9ae84d5cec75da00fa89fe401c72d6e047d11a61f65f70", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cd6111e8017ccd563e65621a4d9a4a1c5cd333df30cebc7face8029cacb4eff6"},
5 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"},
6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
7 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"},
8 | "ex_image_info": {:hex, :ex_image_info, "0.2.5", "38104a042650db8744ef040cb698e39f16683b29e5b103b1d024859bd4ec13f5", [:mix], [], "hexpm", "31c02bef2a62e5d13450773a806c067179a57624d2ac9838bfa028ecdb742215"},
9 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
14 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"},
15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
16 | "opentype": {:hex, :opentype, "0.5.1", "da1b99f2a5070ade233ff44a1b5729cf7f87f45af6154b71bbfe0d825ef6d366", [:mix], [{:unicode_data, "~> 0.7.0", [hex: :unicode_data, repo: "hexpm", optional: false]}], "hexpm", "1868bfe0729fd0c18add911a169eb18b695cfb5acade210359b92bacec2f23c5"},
17 | "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"},
18 | "unicode_data": {:hex, :unicode_data, "0.7.0", "10682f8d592ff1714b84aaa30bcd958e207a21b464f204eed5fc590904a6412b", [:mix], [], "hexpm", "77aaff71b16d502ac61cddb50d42ec7d7dd546ed79b19b7343117079dbdbbec1"},
19 | }
20 |
--------------------------------------------------------------------------------
/test/drawing_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.DrawingTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | alias Mudbrick.Path
6 |
7 | alias Mudbrick.Path.{
8 | Line,
9 | Move,
10 | Output,
11 | Rectangle
12 | }
13 |
14 | alias Mudbrick.TestHelper
15 |
16 | import Mudbrick.TestHelper,
17 | only: [
18 | invalid_colour: 0
19 | ]
20 |
21 | test "can add drawings to a page" do
22 | import Mudbrick
23 |
24 | assert [
25 | "q",
26 | "0 0 m",
27 | "1 0 0 RG",
28 | "1 w",
29 | "50 60 l",
30 | "S",
31 | "0 0 0 RG",
32 | "1 w",
33 | "0 0 50 60 re",
34 | "S",
35 | "Q"
36 | ] =
37 | new()
38 | |> page()
39 | |> path(fn path ->
40 | path
41 | |> Path.move(to: {0, 0})
42 | |> Path.line(to: {50, 60}, colour: {1, 0, 0})
43 | |> Path.rectangle(lower_left: {0, 0}, dimensions: {50, 60})
44 | end)
45 | |> Mudbrick.TestHelper.output()
46 | |> Mudbrick.TestHelper.operations()
47 | end
48 |
49 | test "can construct a rectangle" do
50 | import Path
51 |
52 | path =
53 | new()
54 | |> rectangle(lower_left: {0, 0}, dimensions: {50, 75})
55 |
56 | assert path.sub_paths == [
57 | Rectangle.new(
58 | lower_left: {0, 0},
59 | dimensions: {50, 75},
60 | line_width: 1
61 | )
62 | ]
63 | end
64 |
65 | test "can construct a path with one straight line" do
66 | import Path
67 |
68 | path =
69 | new()
70 | |> move(to: {50, 50})
71 | |> line(to: {50, 50})
72 |
73 | assert path.sub_paths == [
74 | Line.new(to: {50, 50}, width: 1),
75 | Move.new(to: {50, 50})
76 | ]
77 | end
78 |
79 | test "can make an empty path" do
80 | import Path
81 |
82 | assert [] =
83 | operations(fn ->
84 | new()
85 | end)
86 | end
87 |
88 | test "can render a rectangle" do
89 | import Path
90 |
91 | assert [
92 | "q",
93 | "0 0 0 RG",
94 | "1 w",
95 | "0 0 50 75 re",
96 | "S",
97 | "Q"
98 | ] =
99 | operations(fn ->
100 | new()
101 | |> rectangle(lower_left: {0, 0}, dimensions: {50, 75})
102 | end)
103 | end
104 |
105 | test "can draw one path" do
106 | import Path
107 |
108 | assert [
109 | "q",
110 | "0 650 m",
111 | "0 0 0 RG",
112 | "1 w",
113 | "460 750 l",
114 | "S",
115 | "Q"
116 | ] =
117 | operations(fn ->
118 | new()
119 | |> move(to: {0, 650})
120 | |> line(to: {460, 750})
121 | end)
122 | end
123 |
124 | test "can choose line width" do
125 | import Path
126 |
127 | assert [
128 | "q",
129 | "0 650 m",
130 | "0 0 0 RG",
131 | "4.0 w",
132 | "460 750 l",
133 | "S",
134 | "Q"
135 | ] =
136 | operations(fn ->
137 | new()
138 | |> move(to: {0, 650})
139 | |> line(to: {460, 750}, width: 4.0)
140 | end)
141 | end
142 |
143 | test "can choose colour" do
144 | import Path
145 |
146 | assert [
147 | "q",
148 | "0 650 m",
149 | "0 1 0 RG",
150 | "1 w",
151 | "460 750 l",
152 | "S",
153 | "Q"
154 | ] =
155 | operations(fn ->
156 | new()
157 | |> move(to: {0, 650})
158 | |> line(to: {460, 750}, colour: {0, 1, 0})
159 | end)
160 | end
161 |
162 | property "it's an error to set a colour above 1" do
163 | import Path
164 |
165 | check all colour <- invalid_colour() do
166 | e =
167 | assert_raise(Mudbrick.ContentStream.InvalidColour, fn ->
168 | new()
169 | |> move(to: {0, 0})
170 | |> line(to: {100, 0}, colour: colour)
171 | |> Output.to_iodata()
172 | end)
173 |
174 | assert e.message == "tuple must be made of floats or integers between 0 and 1"
175 | end
176 | end
177 |
178 | defp operations(f) do
179 | TestHelper.wrapped_output(fn _ -> f.() end, Mudbrick.Path.Output)
180 | |> Enum.map(&Mudbrick.TestHelper.show/1)
181 | |> Enum.reverse()
182 | end
183 | end
184 |
--------------------------------------------------------------------------------
/test/fixtures/Example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-supply/mudbrick/521d4d546f6b90ea49a4908c26e284c654696ec8/test/fixtures/Example.png
--------------------------------------------------------------------------------
/test/fixtures/JPEG_example_flower.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-supply/mudbrick/521d4d546f6b90ea49a4908c26e284c654696ec8/test/fixtures/JPEG_example_flower.jpg
--------------------------------------------------------------------------------
/test/font_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.FontTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mudbrick.TestHelper
5 |
6 | alias Mudbrick.Document
7 | alias Mudbrick.Font
8 | alias Mudbrick.Font.CMap
9 | alias Mudbrick.Indirect
10 | alias Mudbrick.Stream
11 |
12 | test "embedded OTF fonts have a glyph-unicode mapping to enable copy+paste" do
13 | doc = Mudbrick.new(fonts: %{bodoni: bodoni_regular()})
14 |
15 | font = Document.find_object(doc, &match?(%Font{}, &1))
16 |
17 | assert %Font{to_unicode: mapping} = font.value
18 | assert Document.object_with_ref(doc, mapping.ref)
19 |
20 | assert show(font.value) =~ ~r"/ToUnicode [0-9] 0 R"
21 |
22 | assert %Mudbrick.Font.CMap{} = mapping.value
23 | end
24 |
25 | test "serialised CMaps conform to standard" do
26 | parsed = OpenType.new() |> OpenType.parse(bodoni_regular())
27 |
28 | lines =
29 | Font.CMap.new(parsed: parsed)
30 | |> show()
31 | |> String.split("\n")
32 |
33 | assert [
34 | <<"<>,
35 | ">>",
36 | "stream",
37 | "/CIDInit /ProcSet findresource begin"
38 | | _
39 | ] = lines
40 |
41 | assert "497 beginbfchar" in lines
42 | end
43 |
44 | test "embedded OTF fonts create descendant, descriptor and file objects" do
45 | data = bodoni_regular()
46 | doc = Mudbrick.new(fonts: %{bodoni: data})
47 | font = Document.find_object(doc, &match?(%Font{}, &1))
48 |
49 | assert %Font{
50 | name: :"LibreBodoni-Regular",
51 | type: :Type0,
52 | encoding: :"Identity-H",
53 | descendant: descendant,
54 | resource_identifier: :F1
55 | } = font.value
56 |
57 | assert %Font.CIDFont{
58 | font_name: :"LibreBodoni-Regular",
59 | descriptor: descriptor,
60 | type: :CIDFontType0
61 | } = descendant.value
62 |
63 | assert %Font.Descriptor{
64 | bounding_box: _bbox,
65 | file: file,
66 | font_name: :"LibreBodoni-Regular",
67 | flags: 4
68 | } = descriptor.value
69 |
70 | assert %Mudbrick.Stream{
71 | data: ^data,
72 | additional_entries: %{
73 | Length1: 42_952,
74 | Subtype: :OpenType
75 | }
76 | } = file.value
77 |
78 | assert Document.object_with_ref(doc, descendant.ref)
79 | assert Document.object_with_ref(doc, descriptor.ref)
80 | assert Document.object_with_ref(doc, file.ref)
81 | end
82 |
83 | describe "with compression enabled" do
84 | test "Length is compressed size, Length1 is uncompressed size" do
85 | data = bodoni_regular()
86 | compressed = Mudbrick.compress(data)
87 | doc = Mudbrick.new(compress: true, fonts: %{bodoni: data})
88 | stream = Document.find_object(doc, &match?(%Stream{data: ^compressed}, &1)).value
89 |
90 | assert IO.iodata_length(stream.data) < IO.iodata_length(data)
91 | assert stream.additional_entries[:Length1] == IO.iodata_length(data)
92 | assert stream.length < IO.iodata_length(data)
93 | end
94 |
95 | test "cmap is compressed" do
96 | uncompressed_doc = Mudbrick.new(compress: false, fonts: %{bodoni: bodoni_regular()})
97 | compressed_doc = Mudbrick.new(compress: true, fonts: %{bodoni: bodoni_regular()})
98 | uncompressed_stream = Document.find_object(uncompressed_doc, &match?(%CMap{}, &1)).value
99 | compressed_stream = Document.find_object(compressed_doc, &match?(%CMap{}, &1)).value
100 |
101 | assert IO.iodata_length(Mudbrick.Object.to_iodata(compressed_stream)) <
102 | IO.iodata_length(Mudbrick.Object.to_iodata(uncompressed_stream))
103 | end
104 | end
105 |
106 | describe "serialisation" do
107 | test "with descendant" do
108 | assert %Font{
109 | name: :"LibreBodoni-Regular",
110 | type: :Type0,
111 | encoding: :"Identity-H",
112 | descendant: Indirect.Ref.new(22) |> Indirect.Object.new(%{}),
113 | resource_identifier: :F1,
114 | to_unicode: Indirect.Ref.new(23) |> Indirect.Object.new(%{})
115 | }
116 | |> show() ==
117 | """
118 | <>\
125 | """
126 | end
127 |
128 | test "CID font" do
129 | assert %Font.CIDFont{
130 | font_name: :"LibreBodoni-Regular",
131 | descriptor: Indirect.Ref.new(666) |> Indirect.Object.new(%{}),
132 | type: :CIDFontType0,
133 | default_width: 1000,
134 | widths: [0, 1, 2]
135 | }
136 | |> show() ==
137 | """
138 | <>
145 | /DW 1000
146 | /FontDescriptor 666 0 R
147 | /W [0 [0 1 2]]
148 | >>\
149 | """
150 | end
151 |
152 | test "descriptor" do
153 | assert %Font.Descriptor{
154 | ascent: 928,
155 | bounding_box: [1, 1, 1, 1],
156 | cap_height: 1000,
157 | descent: 111,
158 | font_name: :"LibreBodoni-Regular",
159 | flags: 4,
160 | file: Indirect.Ref.new(99) |> Indirect.Object.new(%{}),
161 | italic_angle: 0.0,
162 | stem_vertical: 80
163 | }
164 | |> show() ==
165 | """
166 | <>\
177 | """
178 | end
179 | end
180 | end
181 |
--------------------------------------------------------------------------------
/test/image_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ImageTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mudbrick
5 | import Mudbrick.TestHelper
6 |
7 | alias Mudbrick.Document
8 | alias Mudbrick.Image
9 |
10 | test "embedding an image adds it to the document" do
11 | data = flower()
12 | doc = new(images: %{flower: data})
13 |
14 | expected_image = %Image{
15 | file: data,
16 | resource_identifier: :I1,
17 | width: 500,
18 | height: 477,
19 | filter: :DCTDecode,
20 | bits_per_component: 8
21 | }
22 |
23 | assert Document.find_object(doc, &(&1 == expected_image))
24 | assert Document.root_page_tree(doc).value.images[:flower].value == expected_image
25 | end
26 |
27 | test "PNGs are currently not supported" do
28 | assert_raise Image.NotSupported, fn ->
29 | new(images: %{my_png: example_png()})
30 | end
31 | end
32 |
33 | test "specifying :auto height maintains aspect ratio" do
34 | assert [
35 | "q",
36 | "100 0 0 95.4 123 456 cm",
37 | "/I1 Do",
38 | "Q"
39 | ] =
40 | new(images: %{flower: flower()})
41 | |> page()
42 | |> image(
43 | :flower,
44 | position: {123, 456},
45 | scale: {100, :auto}
46 | )
47 | |> operations()
48 | end
49 |
50 | test "specifying :auto width maintains aspect ratio" do
51 | assert [
52 | "q",
53 | "52.41090146750524 0 0 50 123 456 cm",
54 | "/I1 Do",
55 | "Q"
56 | ] =
57 | new(images: %{flower: flower()})
58 | |> page()
59 | |> image(
60 | :flower,
61 | position: {123, 456},
62 | scale: {:auto, 50}
63 | )
64 | |> operations()
65 | end
66 |
67 | test "asking for a registered image produces an isolated cm/Do operation" do
68 | assert [
69 | "q",
70 | "100 0 0 100 45 550 cm",
71 | "/I1 Do",
72 | "Q"
73 | ] =
74 | new(images: %{flower: flower()})
75 | |> page()
76 | |> image(
77 | :flower,
78 | position: {45, 550},
79 | scale: {100, 100}
80 | )
81 | |> operations()
82 | end
83 |
84 | describe "serialisation" do
85 | test "produces a JPEG XObject stream" do
86 | [dictionary, _stream] =
87 | Image.new(file: flower(), resource_identifier: :I1)
88 | |> Mudbrick.Object.to_iodata()
89 | |> IO.iodata_to_binary()
90 | |> String.split("stream", parts: 2)
91 |
92 | assert dictionary ==
93 | """
94 | <>
103 | """
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/test/metadata_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.MetadataTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mudbrick
5 | import Mudbrick.Predicates
6 | import Mudbrick.TestHelper
7 |
8 | alias Mudbrick.Document
9 | alias Mudbrick.Stream
10 |
11 | test "gets compressed" do
12 | rendered = new(compress: true) |> render() |> IO.iodata_to_binary()
13 | refute rendered =~ "xpacket"
14 | assert rendered |> has_text?("xpacket")
15 | end
16 |
17 | describe "defaults" do
18 | test "to having no creator names" do
19 | rendered = new() |> render()
20 | refute rendered |> has_text?("dc:creator")
21 | end
22 |
23 | test "to having no creation / modification dates" do
24 | rendered = new() |> render()
25 | refute rendered |> has_text?("xmp:CreateDate")
26 | refute rendered |> has_text?("xmp:ModifyDate")
27 | end
28 |
29 | test "to advertising this software" do
30 | rendered = new() |> render()
31 |
32 | assert rendered |> has_text?("Mudbrick")
33 | assert rendered |> has_text?("Mudbrick")
34 | end
35 | end
36 |
37 | test "metadata is rendered in the metadata stream" do
38 | rendered =
39 | new(
40 | creators: ["Andrew", "Jules"],
41 | creator_tool: "my tool",
42 | producer: "some other software",
43 | title: "my lovely pdf",
44 | create_date: ~U[2012-12-25 12:34:56Z],
45 | modify_date: ~U[2022-12-01 12:34:56Z]
46 | )
47 | |> Document.find_object(fn
48 | %Stream{additional_entries: entries} -> entries[:Type] == :Metadata
49 | _ -> false
50 | end)
51 | |> show()
52 |
53 | assert rendered =~ ~s(/Type /Metadata)
54 | assert rendered =~ ~s(/Subtype /XML)
55 | assert rendered =~ ~s(dc:creator)
56 | assert rendered =~ ~s(rdf:li>Andrew)
57 | assert rendered =~ ~s(rdf:li>Jules)
58 | assert rendered =~ ~s(my lovely pdf)
59 | assert rendered =~ "some other software"
60 | assert rendered =~ "my tool"
61 | assert rendered =~ "2012-12-25T12:34:56Z"
62 | assert rendered =~ "2022-12-01T12:34:56Z"
63 | end
64 |
65 | describe "document and instance IDs" do
66 | test "are the same when fonts and images are the same" do
67 | a =
68 | new(fonts: %{a: bodoni_regular()}, images: %{a: flower()})
69 | |> render()
70 | |> IO.iodata_to_binary()
71 |
72 | b =
73 | new(fonts: %{b: bodoni_regular()}, images: %{b: flower()})
74 | |> render()
75 | |> IO.iodata_to_binary()
76 |
77 | assert ids(a) == ids(b)
78 | end
79 |
80 | test "are different with different fonts" do
81 | a =
82 | new(fonts: %{a: bodoni_regular()})
83 | |> render()
84 | |> IO.iodata_to_binary()
85 |
86 | b =
87 | new(fonts: %{b: bodoni_bold()})
88 | |> render()
89 | |> IO.iodata_to_binary()
90 |
91 | assert ids(a) != ids(b)
92 | end
93 |
94 | test "are unique to options" do
95 | opts_1 = []
96 | opts_2 = [title: "hi there"]
97 |
98 | [ids_1, ids_2] =
99 | for opts <- [opts_1, opts_2] do
100 | rendered =
101 | new(opts)
102 | |> Document.find_object(fn
103 | %Stream{additional_entries: entries} -> entries[:Type] == :Metadata
104 | _ -> false
105 | end)
106 | |> show()
107 |
108 | {document_id, instance_id} = ids(rendered)
109 |
110 | assert document_id != instance_id
111 |
112 | {document_id, instance_id}
113 | end
114 |
115 | assert ids_1 != ids_2
116 | end
117 |
118 | defp ids(rendered) do
119 | [document_id] =
120 | Regex.run(~r/xmpMM:DocumentID>(.*?)(.*?) Indirect.Object.new(PageTree.new())
13 |
14 | metadata_obj =
15 | Indirect.Ref.new(43) |> Indirect.Object.new(Stream.new(data: ""))
16 |
17 | assert Indirect.Ref.new(999)
18 | |> Indirect.Object.new(Catalog.new(page_tree: page_tree_obj, metadata: metadata_obj))
19 | |> show() == """
20 | 999 0 obj
21 | <>
25 | endobj\
26 | """
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/mudbrick/indirect_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.IndirectTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mudbrick.TestHelper
5 |
6 | alias Mudbrick.Indirect
7 |
8 | test "object includes object number, static generation and contents" do
9 | assert Indirect.Ref.new(12)
10 | |> Indirect.Object.new("Brillig")
11 | |> show() ==
12 | """
13 | 12 0 obj
14 | (Brillig)
15 | endobj\
16 | """
17 | end
18 |
19 | test "ref has number, static generation and letter R" do
20 | assert 12 |> Indirect.Ref.new() |> show() == "12 0 R"
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/mudbrick/page_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.PageTest do
2 | use ExUnit.Case, async: true
3 | doctest Mudbrick.Page
4 | end
5 |
--------------------------------------------------------------------------------
/test/mudbrick/page_tree_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.PageTreeTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mudbrick.TestHelper
5 |
6 | alias Mudbrick.Document
7 |
8 | test "is a dictionary of pages, fonts and images" do
9 | doc =
10 | Mudbrick.new(
11 | fonts: %{bodoni: bodoni_regular()},
12 | images: %{flower: flower()}
13 | )
14 |
15 | assert doc |> Document.root_page_tree() |> show() ==
16 | """
17 | 8 0 obj
18 | <>
23 | /XObject <>
25 | >>
26 | >>
27 | endobj\
28 | """
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/mudbrick/parser/roundtrip_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ParseRoundtripTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | import Mudbrick
6 | import Mudbrick.TestHelper
7 |
8 | alias Mudbrick.Parser
9 |
10 | property "documents" do
11 | check all fonts <- fonts(),
12 | document_options <- document_options(),
13 | pages_options <- list_of(page_options()),
14 | images_options <- list_of(image_options()),
15 | text_writes <- list_of(string(:alphanumeric)),
16 | max_runs: 75 do
17 | doc = new(Keyword.merge(document_options, fonts: fonts))
18 | images = Keyword.get(document_options, :images, %{})
19 |
20 | input =
21 | Enum.zip([
22 | pages_options,
23 | cycle(images_options),
24 | cycle(Map.keys(images)),
25 | cycle(text_writes)
26 | ])
27 | |> Enum.reduce(doc, fn {
28 | page_options,
29 | image_options,
30 | image_identifier,
31 | text_write
32 | },
33 | context ->
34 | context
35 | |> page(page_options)
36 | |> then(fn page_context ->
37 | if Enum.empty?(image_options) or Enum.empty?(document_options[:images]) do
38 | page_context
39 | else
40 | text_options =
41 | if fonts && map_size(fonts) > 1 do
42 | [font: Map.keys(fonts) |> List.first()]
43 | else
44 | []
45 | end
46 |
47 | page_context
48 | |> image(image_identifier, image_options)
49 | |> then(fn context ->
50 | if fonts && map_size(fonts) > 0 do
51 | text(context, text_write, text_options)
52 | else
53 | context
54 | end
55 | end)
56 | end
57 | end)
58 | end)
59 | |> render()
60 |
61 | parsed =
62 | input
63 | |> IO.iodata_to_binary()
64 | |> Parser.parse()
65 | |> render()
66 |
67 | assert parsed == input
68 | end
69 | end
70 |
71 | property "objects" do
72 | base_object =
73 | one_of([
74 | atom(:alphanumeric),
75 | boolean(),
76 | integer(),
77 | float(min: -999, max: 999),
78 | string(:alphanumeric)
79 | ])
80 |
81 | check all input <-
82 | one_of([
83 | base_object,
84 | list_of(base_object),
85 | list_of(list_of(base_object)),
86 | map_of(atom(:alphanumeric), base_object),
87 | map_of(atom(:alphanumeric), map_of(atom(:alphanumeric), base_object))
88 | ]),
89 | max_runs: 50 do
90 | assert input
91 | |> Mudbrick.Object.to_iodata()
92 | |> Parser.to_mudbrick(:object) == input
93 | end
94 | end
95 |
96 | test "with an image" do
97 | input =
98 | new(images: %{flower: flower()})
99 | |> page()
100 | |> image(
101 | :flower,
102 | scale: {100, 100},
103 | position: {0, 0}
104 | )
105 | |> render()
106 |
107 | parsed =
108 | input
109 | |> IO.iodata_to_binary()
110 | |> Parser.parse()
111 | |> render()
112 |
113 | assert parsed == input
114 | end
115 |
116 | test "custom page size" do
117 | input =
118 | new()
119 | |> page(size: {400, 100})
120 | |> render()
121 |
122 | parsed =
123 | input
124 | |> IO.iodata_to_binary()
125 | |> Parser.parse()
126 | |> render()
127 |
128 | assert parsed == input
129 | end
130 |
131 | test "with rectangle" do
132 | input =
133 | new()
134 | |> page(size: {100, 100})
135 | |> path(fn path ->
136 | Mudbrick.Path.rectangle(path, lower_left: {0, 0}, dimensions: {50, 60})
137 | end)
138 | |> render()
139 |
140 | parsed =
141 | input
142 | |> IO.iodata_to_binary()
143 | |> Parser.parse()
144 | |> render()
145 |
146 | assert parsed == input
147 | end
148 |
149 | test "with underline" do
150 | input =
151 | new(fonts: %{poop: bodoni_bold()})
152 | |> page(size: {400, 100})
153 | |> text(
154 | [{"Warning\n", underline: [width: 0.5]}],
155 | font: :poop,
156 | font_size: 70,
157 | position: {7, 30}
158 | )
159 | |> render()
160 |
161 | parsed =
162 | input
163 | |> IO.iodata_to_binary()
164 | |> Parser.parse()
165 | |> render()
166 |
167 | assert parsed == input
168 | end
169 |
170 | test "PDF with text" do
171 | input =
172 | new(
173 | fonts: %{
174 | bodoni: bodoni_regular(),
175 | franklin: franklin_regular()
176 | }
177 | )
178 | |> page()
179 | |> text("hello, bodoni", font: :bodoni)
180 | |> text("hello, franklin", font: :franklin)
181 | |> render()
182 |
183 | parsed =
184 | input
185 | |> IO.iodata_to_binary()
186 | |> Parser.parse()
187 | |> render()
188 |
189 | assert parsed == input
190 | end
191 |
192 | test "PDF with text, compressed" do
193 | input =
194 | new(
195 | compress: true,
196 | fonts: %{
197 | bodoni: bodoni_regular(),
198 | franklin: franklin_regular()
199 | }
200 | )
201 | |> page()
202 | |> text("hello, bodoni", font: :bodoni)
203 | |> text("hello, franklin", font: :franklin)
204 | |> render()
205 |
206 | parsed =
207 | input
208 | |> IO.iodata_to_binary()
209 | |> Parser.parse()
210 | |> Mudbrick.render()
211 |
212 | assert parsed == input
213 | end
214 |
215 | defp cycle([]), do: []
216 | defp cycle(enumerable), do: Stream.cycle(enumerable)
217 | end
218 |
--------------------------------------------------------------------------------
/test/mudbrick/parser/text_content_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ParseTextContentTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | import Mudbrick
6 | import Mudbrick.TestHelper
7 |
8 | alias Mudbrick.Parser
9 |
10 | setup do
11 | doc =
12 | new(fonts: %{bodoni: bodoni_regular(), franklin: franklin_regular()})
13 | |> page()
14 | |> text(
15 | {
16 | "hello, world!",
17 | underline: [width: 1]
18 | },
19 | font: :bodoni
20 | )
21 | |> text("hello in another font", font: :franklin)
22 | |> Mudbrick.Document.finish()
23 |
24 | obj = Enum.find(doc.objects, &(%Mudbrick.ContentStream{} = &1.value))
25 |
26 | [
27 | {:dictionary, [pair: [name: "Length", integer: [_]]]},
28 | "stream",
29 | stream
30 | ] =
31 | obj.value
32 | |> Mudbrick.Object.to_iodata()
33 | |> IO.iodata_to_binary()
34 | |> Parser.parse(:stream)
35 |
36 | %{stream: stream}
37 | end
38 |
39 | test "can parse a text block with negative kerns" do
40 | raw =
41 | """
42 | BT
43 | [ <00F3> -32 <0010> ] TJ
44 | ET
45 | """
46 |
47 | assert Parser.parse(raw, :content_blocks) == [
48 | text_block: [
49 | BT: [],
50 | TJ: [glyph_id: "00F3", offset: {:integer, ["-", "32"]}, glyph_id: "0010"],
51 | ET: []
52 | ]
53 | ]
54 | end
55 |
56 | test "can turn text content to Mudbrick", %{stream: stream} do
57 | assert %Mudbrick.ContentStream{
58 | compress: false,
59 | operations: [
60 | %Mudbrick.ContentStream.ET{},
61 | %Mudbrick.ContentStream.TJ{
62 | auto_kern: true,
63 | kerned_text: _
64 | },
65 | %Mudbrick.ContentStream.Rg{stroking: false, r: 0, g: 0, b: 0},
66 | %Mudbrick.ContentStream.TL{leading: 14.399999999999999},
67 | %Mudbrick.ContentStream.Tf{font_identifier: :F2, size: "12"},
68 | %Mudbrick.ContentStream.BT{},
69 | %Mudbrick.ContentStream.ET{},
70 | %Mudbrick.ContentStream.TJ{
71 | auto_kern: true,
72 | kerned_text: _
73 | },
74 | %Mudbrick.ContentStream.Rg{stroking: false, r: 0, g: 0, b: 0},
75 | %Mudbrick.ContentStream.TL{leading: 14.399999999999999},
76 | %Mudbrick.ContentStream.Tf{font_identifier: :F1, size: "12"},
77 | %Mudbrick.ContentStream.BT{},
78 | %Mudbrick.ContentStream.QPop{},
79 | %Mudbrick.ContentStream.S{},
80 | %Mudbrick.ContentStream.L{coords: {65.46, -1.2}},
81 | %Mudbrick.ContentStream.W{width: 1},
82 | %Mudbrick.ContentStream.Rg{stroking: true, r: 0, g: 0, b: 0},
83 | %Mudbrick.ContentStream.M{coords: {+0.0, -1.2}},
84 | %Mudbrick.ContentStream.QPush{}
85 | ],
86 | page: nil
87 | } = Parser.to_mudbrick(stream, :content_blocks)
88 | end
89 |
90 | test "can parse text content to AST", %{stream: stream} do
91 | assert [
92 | {:graphics_block,
93 | [
94 | q: [],
95 | m: [real: ["0", ".", "0"], real: ["-", "1", ".", "2"]],
96 | RG: [integer: ["0"], integer: ["0"], integer: ["0"]],
97 | w: [integer: ["1"]],
98 | l: [real: ["65", ".", "46"], real: ["-", "1", ".", "2"]],
99 | S: [],
100 | Q: []
101 | ]},
102 | {:text_block,
103 | [
104 | BT: [],
105 | Tf: ["1", "12"],
106 | TL: [real: ["14", ".", "399999999999999"]],
107 | rg: [integer: ["0"], integer: ["0"], integer: ["0"]],
108 | TJ: [
109 | glyph_id: "00D5",
110 | offset: {:integer, ["24"]},
111 | glyph_id: "00C0",
112 | glyph_id: "00ED",
113 | glyph_id: "00ED",
114 | glyph_id: "00FC",
115 | glyph_id: "0195",
116 | glyph_id: "01B7",
117 | glyph_id: "0138",
118 | glyph_id: "00FC",
119 | glyph_id: "010F",
120 | offset: {:integer, ["12"]},
121 | glyph_id: "00ED",
122 | glyph_id: "00BB",
123 | glyph_id: "0197"
124 | ],
125 | ET: []
126 | ]},
127 | {:text_block,
128 | [
129 | BT: [],
130 | Tf: ["2", "12"],
131 | TL: [real: ["14", ".", "399999999999999"]],
132 | rg: [integer: ["0"], integer: ["0"], integer: ["0"]],
133 | TJ: [
134 | glyph_id: "0105",
135 | offset: {:integer, ["44"]},
136 | glyph_id: "00EA",
137 | glyph_id: "011E",
138 | glyph_id: "011E",
139 | glyph_id: "012C",
140 | glyph_id: "01F0",
141 | glyph_id: "0109",
142 | glyph_id: "0125",
143 | glyph_id: "01F0",
144 | glyph_id: "00C3",
145 | glyph_id: "0125",
146 | glyph_id: "012C",
147 | offset: {:integer, ["35"]},
148 | glyph_id: "015A",
149 | offset: {:integer, ["13"]},
150 | glyph_id: "0105",
151 | offset: {:integer, ["13"]},
152 | glyph_id: "00EA",
153 | offset: {:integer, ["63"]},
154 | glyph_id: "014B",
155 | glyph_id: "01F0",
156 | offset: {:integer, ["13"]},
157 | glyph_id: "00FF",
158 | glyph_id: "012C",
159 | glyph_id: "0125",
160 | glyph_id: "015A"
161 | ],
162 | ET: []
163 | ]}
164 | ] =
165 | stream
166 | |> Parser.parse(:content_blocks)
167 | end
168 | end
169 |
--------------------------------------------------------------------------------
/test/mudbrick/parser_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ParserTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | doctest Mudbrick.Parser
6 |
7 | alias Mudbrick.Indirect
8 | alias Mudbrick.Parser
9 |
10 | import Mudbrick.TestHelper
11 |
12 | property "metadata" do
13 | check all options <- metadata_options(),
14 | metadata <- constant(Mudbrick.Metadata.render(options)),
15 | max_runs: 25 do
16 | expected_options =
17 | [
18 | create_date: "",
19 | creators: [],
20 | creator_tool: "Mudbrick",
21 | modify_date: "",
22 | producer: "Mudbrick",
23 | title: ""
24 | ]
25 | |> Keyword.merge(options, fn _key, _default, provided -> provided end)
26 | |> Enum.sort()
27 |
28 | assert Parser.metadata(metadata) == expected_options
29 | end
30 | end
31 |
32 | describe "streams" do
33 | test "compressed" do
34 | stream =
35 | Mudbrick.Stream.new(
36 | compress: true,
37 | data: "oooooooooooo"
38 | )
39 |
40 | obj =
41 | Indirect.Ref.new(1)
42 | |> Indirect.Object.new(stream)
43 |
44 | assert Parser.parse(Mudbrick.Object.to_iodata(obj), :indirect_object) == [
45 | indirect_object: [
46 | 1,
47 | 0,
48 | {:dictionary,
49 | [
50 | pair: [name: "Filter", array: [name: "FlateDecode"]],
51 | pair: [name: "Length", integer: ["11"]]
52 | ]},
53 | "stream",
54 | "x\x9C\xCB\xCFG\0\0!\xDE\x055"
55 | ]
56 | ]
57 | end
58 |
59 | test "uncompressed" do
60 | stream =
61 | Mudbrick.Stream.new(
62 | compress: false,
63 | data: "oooooooooooo"
64 | )
65 |
66 | obj =
67 | Indirect.Ref.new(1)
68 | |> Indirect.Object.new(stream)
69 |
70 | assert Parser.parse(Mudbrick.Object.to_iodata(obj), :indirect_object) == [
71 | indirect_object: [
72 | 1,
73 | 0,
74 | {:dictionary, [pair: [name: "Length", integer: ["12"]]]},
75 | "stream",
76 | "oooooooooooo"
77 | ]
78 | ]
79 | end
80 | end
81 |
82 | test "strings bounded by parens" do
83 | assert Parser.parse("(hello, world!)", :string) == [string: ["hello, world!"]]
84 | assert Parser.parse("()", :string) == [string: []]
85 | end
86 |
87 | test "real numbers" do
88 | assert Parser.parse("0.1", :real) == [real: ["0", ".", "1"]]
89 | assert Parser.parse("0.1", :number) == [{:real, ["0", ".", "1"]}]
90 | end
91 |
92 | test "booleans" do
93 | assert Parser.parse("true", :boolean) == [boolean: true]
94 | end
95 |
96 | test "empty dictionary" do
97 | assert Parser.parse("<<>>", :dictionary) == [{:dictionary, []}]
98 | end
99 |
100 | test "nonempty dictionary" do
101 | assert Parser.parse(
102 | """
103 | <>\
107 | """,
108 | :dictionary
109 | ) ==
110 | [
111 | dictionary: [
112 | {:pair, [name: "Name", integer: ["123"]]},
113 | {:pair, [name: "Type", name: "Font"]},
114 | {:pair, [name: "Pages", indirect_reference: ["1", "0", "R"]]},
115 | {:pair, [name: "Page", string: ["hello"]]}
116 | ]
117 | ]
118 | end
119 |
120 | test "dictionary in a dictionary" do
121 | assert Parser.parse("<>>>", :dictionary) ==
122 | [
123 | dictionary: [
124 | pair: [
125 | name: "Font",
126 | dictionary: [
127 | {:pair,
128 | [
129 | name: "Type",
130 | name: "CIDFont"
131 | ]}
132 | ]
133 | ]
134 | ]
135 | ]
136 | end
137 |
138 | test "empty array" do
139 | assert Parser.parse("[]", :array) == [{:array, []}]
140 | end
141 |
142 | test "empty array inside array" do
143 | assert Parser.parse("[[]]", :array) == [{:array, [array: []]}]
144 | end
145 |
146 | test "nonempty nested array" do
147 | assert Parser.parse("[[true false] true]", :array) == [
148 | {:array, [array: [boolean: true, boolean: false], boolean: true]}
149 | ]
150 | end
151 |
152 | test "array with negative integers" do
153 | assert Parser.parse("[-416 -326 1379 924]", :array) == [
154 | {:array,
155 | [
156 | integer: ["-", "416"],
157 | integer: ["-", "326"],
158 | integer: ["1379"],
159 | integer: ["924"]
160 | ]}
161 | ]
162 | end
163 |
164 | test "true on its own in an array" do
165 | assert Parser.parse("[true]", :array) == [
166 | {:array, [boolean: true]}
167 | ]
168 | end
169 |
170 | test "mixed graphics and text blocks" do
171 | assert [
172 | graphics_block: [
173 | q: [],
174 | cm: [
175 | integer: ["100"],
176 | integer: ["0"],
177 | integer: ["0"],
178 | integer: ["100"],
179 | integer: ["0"],
180 | integer: ["0"]
181 | ],
182 | Do: ["1"],
183 | Q: []
184 | ],
185 | graphics_block: [
186 | q: [],
187 | m: [real: ["0", ".", "0"], real: ["-", "1", ".", "2"]],
188 | RG: [integer: ["0"], integer: ["0"], integer: ["0"]],
189 | w: [integer: ["1"]],
190 | l: [real: ["65", ".", "46"], real: ["-", "1", ".", "2"]],
191 | re: [integer: ["0"], integer: ["0"], integer: ["50"], integer: ["60"]],
192 | S: [],
193 | Q: []
194 | ],
195 | text_block: [
196 | BT: [],
197 | Tf: ["1", "12"],
198 | TL: [real: ["14", ".", "399999999999999"]],
199 | Td: [integer: ["7"], integer: ["30"]],
200 | rg: [integer: ["0"], integer: ["0"], integer: ["0"]],
201 | TJ: [
202 | glyph_id: "00D5",
203 | offset: {:integer, ["24"]},
204 | glyph_id: "00C0",
205 | glyph_id: "00ED",
206 | glyph_id: "00ED",
207 | glyph_id: "00FC",
208 | glyph_id: "0195",
209 | glyph_id: "01B7",
210 | glyph_id: "0138",
211 | glyph_id: "00FC",
212 | glyph_id: "010F",
213 | offset: {:integer, ["12"]},
214 | glyph_id: "00ED",
215 | glyph_id: "00BB",
216 | glyph_id: "0197"
217 | ],
218 | ET: []
219 | ],
220 | text_block: [
221 | BT: [],
222 | Tf: ["2", "12"],
223 | TL: [real: ["14", ".", "399999999999999"]],
224 | rg: [integer: ["0"], integer: ["0"], integer: ["0"]],
225 | TJ: [
226 | glyph_id: "0105",
227 | offset: {:integer, ["44"]},
228 | glyph_id: "00EA",
229 | glyph_id: "011E",
230 | glyph_id: "011E",
231 | glyph_id: "012C",
232 | glyph_id: "01F0",
233 | glyph_id: "0109",
234 | glyph_id: "0125",
235 | glyph_id: "01F0",
236 | glyph_id: "00C3",
237 | glyph_id: "0125",
238 | glyph_id: "012C",
239 | offset: {:integer, ["35"]},
240 | glyph_id: "015A",
241 | offset: {:integer, ["13"]},
242 | glyph_id: "0105",
243 | offset: {:integer, ["13"]},
244 | glyph_id: "00EA",
245 | offset: {:integer, ["63"]},
246 | glyph_id: "014B",
247 | glyph_id: "01F0",
248 | offset: {:integer, ["13"]},
249 | glyph_id: "00FF",
250 | glyph_id: "012C",
251 | glyph_id: "0125",
252 | glyph_id: "015A"
253 | ],
254 | ET: []
255 | ]
256 | ] =
257 | Parser.parse(
258 | """
259 | q
260 | 100 0 0 100 0 0 cm
261 | /I1 Do
262 | Q
263 | q
264 | 0.0 -1.2 m
265 | 0 0 0 RG
266 | 1 w
267 | 65.46 -1.2 l
268 | 0 0 50 60 re
269 | S
270 | Q
271 | BT
272 | /F1 12 Tf
273 | 14.399999999999999 TL
274 | 7 30 Td
275 | 0 0 0 rg
276 | [ <00D5> 24 <00C0> <00ED> <00ED> <00FC> <0195> <01B7> <0138> <00FC> <010F> 12 <00ED> <00BB> <0197> ] TJ
277 | ET
278 | BT
279 | /F2 12 Tf
280 | 14.399999999999999 TL
281 | 0 0 0 rg
282 | [ <0105> 44 <00EA> <011E> <011E> <012C> <01F0> <0109> <0125> <01F0> <00C3> <0125> <012C> 35 <015A> 13 <0105> 13 <00EA> 63 <014B> <01F0> 13 <00FF> <012C> <0125> <015A> ] TJ
283 | ET
284 | """,
285 | :content_blocks
286 | )
287 | end
288 |
289 | test "text blocks" do
290 | assert Parser.parse(
291 | """
292 | BT
293 | q
294 | 7.0 23.0 m
295 | 0 0 0 RG
296 | 0.5 w
297 | 293.29999999999995 23.0 l
298 | /F1 12 Tf
299 | 14.399999999999999 TL
300 | 0 0 0 rg
301 | [ <00D5> 24 <00C0> <00ED> <00ED> <00FC> <0195> <01B7> <0138> <00FC> <010F> 12 <00ED> <00BB> <0197> ] TJ
302 | T*
303 | S
304 | Q
305 | ET
306 | BT
307 | /F2 12 Tf
308 | 14.399999999999999 TL
309 | 0 0 0 rg
310 | [ <0105> 44 <00EA> <011E> <011E> <012C> <01F0> <0109> <0125> <01F0> <00C3> <0125> <012C> 35 <015A> 13 <0105> 1 3 <00EA> 63 <014B> <01F0> 13 <00FF> <012C> <0125> <015A> ] TJ
311 | ET\
312 | """,
313 | :content_blocks
314 | ) == [
315 | text_block: [
316 | BT: [],
317 | q: [],
318 | m: [real: ["7", ".", "0"], real: ["23", ".", "0"]],
319 | RG: [integer: ["0"], integer: ["0"], integer: ["0"]],
320 | w: [real: ["0", ".", "5"]],
321 | l: [real: ["293", ".", "29999999999995"], real: ["23", ".", "0"]],
322 | Tf: ["1", "12"],
323 | TL: [real: ["14", ".", "399999999999999"]],
324 | rg: [integer: ["0"], integer: ["0"], integer: ["0"]],
325 | TJ: [
326 | glyph_id: "00D5",
327 | offset: {:integer, ["24"]},
328 | glyph_id: "00C0",
329 | glyph_id: "00ED",
330 | glyph_id: "00ED",
331 | glyph_id: "00FC",
332 | glyph_id: "0195",
333 | glyph_id: "01B7",
334 | glyph_id: "0138",
335 | glyph_id: "00FC",
336 | glyph_id: "010F",
337 | offset: {:integer, ["12"]},
338 | glyph_id: "00ED",
339 | glyph_id: "00BB",
340 | glyph_id: "0197"
341 | ],
342 | TStar: [],
343 | S: [],
344 | Q: [],
345 | ET: []
346 | ],
347 | text_block: [
348 | BT: [],
349 | Tf: ["2", "12"],
350 | TL: [real: ["14", ".", "399999999999999"]],
351 | rg: [integer: ["0"], integer: ["0"], integer: ["0"]],
352 | TJ: [
353 | glyph_id: "0105",
354 | offset: {:integer, ["44"]},
355 | glyph_id: "00EA",
356 | glyph_id: "011E",
357 | glyph_id: "011E",
358 | glyph_id: "012C",
359 | glyph_id: "01F0",
360 | glyph_id: "0109",
361 | glyph_id: "0125",
362 | glyph_id: "01F0",
363 | glyph_id: "00C3",
364 | glyph_id: "0125",
365 | glyph_id: "012C",
366 | offset: {:integer, ["35"]},
367 | glyph_id: "015A",
368 | offset: {:integer, ["13"]},
369 | glyph_id: "0105",
370 | offset: {:integer, ["1"]},
371 | offset: {:integer, ["3"]},
372 | glyph_id: "00EA",
373 | offset: {:integer, ["63"]},
374 | glyph_id: "014B",
375 | glyph_id: "01F0",
376 | offset: {:integer, ["13"]},
377 | glyph_id: "00FF",
378 | glyph_id: "012C",
379 | glyph_id: "0125",
380 | glyph_id: "015A"
381 | ],
382 | ET: []
383 | ]
384 | ]
385 | end
386 | end
387 |
--------------------------------------------------------------------------------
/test/mudbrick/serialisation/object_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.ObjectTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | import Mudbrick.TestHelper
6 |
7 | describe "dictionary (map)" do
8 | test "is enclosed in double angle brackets" do
9 | example =
10 | %{
11 | Version: 0.01,
12 | Type: :Example,
13 | SubType: :DictionaryExample,
14 | SubDictionary: %{Item1: 0.4},
15 | StringItem: "a string",
16 | IntegerItem: 12,
17 | AString: "hi there"
18 | }
19 |
20 | assert show(example) ==
21 | """
22 | <>
28 | /SubType /DictionaryExample
29 | /Version 0.01
30 | >>\
31 | """
32 | end
33 | end
34 |
35 | describe "names (atoms)" do
36 | test "are prefixed with a solidus" do
37 | assert show(:Name1) == "/Name1"
38 | assert show(:ASomewhatLongerName) == "/ASomewhatLongerName"
39 |
40 | assert show(:"A;Name_With-Various***Characters?") ==
41 | "/A;Name_With-Various***Characters?"
42 |
43 | assert show(:"1.2") == "/1.2"
44 | end
45 |
46 | test "nils become the word 'nil', mostly to avoid test problems" do
47 | assert show(nil) == "/nil"
48 | end
49 |
50 | test "literal whitespace is escaped as hex" do
51 | assert show(:"hi there") == "/hi#20there"
52 | end
53 |
54 | property "characters outside of ! to ~ don't appear as literals" do
55 | check all s <- string([0..(?! - 1), (?~ + 1)..999], min_length: 1) do
56 | rendered = show(:"#{s}")
57 | refute rendered =~ s
58 | assert rendered =~ "#"
59 | end
60 | end
61 | end
62 |
63 | describe "lists" do
64 | test "become PDF arrays, elements separated by space" do
65 | assert show([]) == "[]"
66 |
67 | assert show([549, 3.14, false, "Ralph", :SomeName]) ==
68 | "[549 3.14 false (Ralph) /SomeName]"
69 | end
70 | end
71 |
72 | describe "strings" do
73 | test "escape certain characters" do
74 | assert show("\n \r \t \b \f ) ( \\ #{[0xDDD]}") ==
75 | "(\\n \\r \\t \\b \\f \\) \\( \\ \\ddd)"
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/test/mudbrick/stream_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.StreamTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | import Mudbrick.TestHelper
6 |
7 | property "compresses data when there's a saving" do
8 | check all uncompressed <- string(:alphanumeric, min_length: 150), max_runs: 200 do
9 | result =
10 | Mudbrick.Stream.new(compress: true, data: uncompressed)
11 | |> Mudbrick.Object.to_iodata()
12 | |> IO.iodata_to_binary()
13 |
14 | assert result =~ "FlateDecode"
15 | end
16 | end
17 |
18 | test "doesn't compress data when there's no saving" do
19 | uncompressed = "1234567890abcdefghijklmnopqrstuvwxyz"
20 |
21 | result =
22 | Mudbrick.Stream.new(compress: true, data: uncompressed)
23 | |> Mudbrick.Object.to_iodata()
24 | |> IO.iodata_to_binary()
25 |
26 | refute result =~ "FlateDecode"
27 | end
28 |
29 | test "includes length and stream markers when serialised" do
30 | serialised =
31 | Mudbrick.Stream.new(data: bodoni_regular())
32 | |> Mudbrick.Object.to_iodata()
33 | |> IO.iodata_to_binary()
34 |
35 | assert String.starts_with?(serialised, """
36 | <>
38 | stream\
39 | """)
40 |
41 | assert String.ends_with?(serialised, """
42 | endstream\
43 | """)
44 | end
45 |
46 | test "includes additional entries merged into the dictionary" do
47 | assert Mudbrick.Stream.new(data: "yo", additional_entries: %{Hi: :There})
48 | |> show() ==
49 | """
50 | <>
53 | stream
54 | yo
55 | endstream\
56 | """
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/mudbrick/text_block_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.TextBlockTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mudbrick.TestHelper, only: [bodoni_regular: 0]
5 |
6 | alias Mudbrick.Font
7 | alias Mudbrick.TextBlock
8 | alias Mudbrick.TextBlock.Line
9 | alias Mudbrick.TextBlock.Line.Part
10 |
11 | @font (Mudbrick.new(fonts: %{bodoni: bodoni_regular()})
12 | |> Mudbrick.Document.find_object(&match?(%Font{}, &1))).value
13 |
14 | test "single write is divided into lines" do
15 | block =
16 | TextBlock.new(
17 | font: @font,
18 | font_size: 10,
19 | position: {400, 500}
20 | )
21 | |> TextBlock.write("first\nsecond\nthird", colour: {0, 0, 0})
22 |
23 | assert [
24 | %Line{
25 | leading: 12.0,
26 | parts: [
27 | %Part{
28 | colour: {0, 0, 0},
29 | font_size: 10,
30 | text: "third"
31 | }
32 | ]
33 | },
34 | %Line{
35 | leading: 12.0,
36 | parts: [
37 | %Part{
38 | colour: {0, 0, 0},
39 | font_size: 10,
40 | text: "second"
41 | }
42 | ]
43 | },
44 | %Line{
45 | leading: 12.0,
46 | parts: [
47 | %Part{
48 | colour: {0, 0, 0},
49 | font_size: 10,
50 | text: "first"
51 | }
52 | ]
53 | }
54 | ] = block.lines
55 | end
56 |
57 | test "offsets from left get set" do
58 | block =
59 | TextBlock.new(
60 | colour: {0, 0, 1},
61 | font: @font,
62 | font_size: 10,
63 | position: {400, 500},
64 | leading: 14
65 | )
66 | |> TextBlock.write("first ", colour: {1, 0, 0})
67 | |> TextBlock.write("""
68 | line
69 | second line
70 | """)
71 | |> TextBlock.write("third ", leading: 16)
72 | |> TextBlock.write("line")
73 | |> TextBlock.write("\nfourth", colour: {0, 1, 0}, font_size: 24)
74 |
75 | part_offsets =
76 | for line <- block.lines do
77 | for part <- line.parts do
78 | {part.text, part.left_offset}
79 | end
80 | end
81 |
82 | assert part_offsets == [
83 | [{"fourth", {0.0, -44.0}}],
84 | [{"line", {25.38, -28.0}}, {"third ", {0.0, -28.0}}],
85 | [{"second line", {0.0, -14.0}}],
86 | [{"line", {20.31, 0.0}}, {"first ", {0.0, 0.0}}]
87 | ]
88 | end
89 |
90 | test "writes get divided into lines and parts" do
91 | block =
92 | TextBlock.new(
93 | colour: {0, 0, 1},
94 | font: @font,
95 | font_size: 10,
96 | position: {400, 500},
97 | leading: 14
98 | )
99 | |> TextBlock.write("first ", colour: {1, 0, 0})
100 | |> TextBlock.write("""
101 | line
102 | second line
103 | """)
104 | |> TextBlock.write("third ", leading: 16)
105 | |> TextBlock.write("line")
106 | |> TextBlock.write("\nfourth", colour: {0, 1, 0}, font_size: 24)
107 |
108 | assert [
109 | %Line{
110 | leading: 14,
111 | parts: [
112 | %Part{colour: {0, 1, 0}, font_size: 24, text: "fourth"}
113 | ]
114 | },
115 | %Line{
116 | leading: 16,
117 | parts: [
118 | %Part{colour: {0, 0, 1}, font_size: 10, text: "line"},
119 | %Part{colour: {0, 0, 1}, font_size: 10, text: "third "}
120 | ]
121 | },
122 | %Line{
123 | leading: 14,
124 | parts: [%Part{colour: {0, 0, 1}, font_size: 10, text: "second line"}]
125 | },
126 | %Line{
127 | leading: 14,
128 | parts: [
129 | %Part{colour: {0, 0, 1}, font_size: 10, text: "line"},
130 | %Part{colour: {1, 0, 0}, font_size: 10, text: "first "}
131 | ]
132 | }
133 | ] = block.lines
134 | end
135 |
136 | describe "underline" do
137 | test "can be set on a single line" do
138 | block =
139 | TextBlock.new(
140 | font: @font,
141 | font_size: 10,
142 | position: {400, 500}
143 | )
144 | |> TextBlock.write("this is ")
145 | |> TextBlock.write("underlined", underline: [width: 1])
146 |
147 | assert [
148 | %Line{
149 | leading: 12.0,
150 | parts: [
151 | %Part{
152 | colour: {0, 0, 0},
153 | font_size: 10,
154 | text: "underlined",
155 | underline: [width: 1]
156 | },
157 | %Part{colour: {0, 0, 0}, font_size: 10, text: "this is "}
158 | ]
159 | }
160 | ] = block.lines
161 | end
162 |
163 | test "length of line is different when kerned" do
164 | line = fn opts ->
165 | output(fn %{fonts: fonts} ->
166 | TextBlock.new(Keyword.merge(opts, font: fonts.regular))
167 | |> TextBlock.write("underlined", underline: [width: 1])
168 | end)
169 | |> operations()
170 | |> Enum.find(fn op -> String.ends_with?(op, " l") end)
171 | end
172 |
173 | line_with_kerning = line.(auto_kern: true)
174 | line_without_kerning = line.(auto_kern: false)
175 |
176 | assert line_with_kerning != line_without_kerning
177 | end
178 | end
179 |
180 | describe "leading" do
181 | test "is set correctly for lines composed with writes" do
182 | output(fn %{fonts: fonts} ->
183 | heading_leading = 70
184 | overlap_leading = 20
185 | # 120% of default 80 font size
186 | expected_final_leading = 96.0
187 |
188 | text_block =
189 | TextBlock.new(
190 | align: :left,
191 | font: fonts.regular,
192 | font_size: 80,
193 | position: {0, 500}
194 | )
195 | |> TextBlock.write("Warning!\n", font_size: 140, leading: heading_leading)
196 | |> TextBlock.write("Leading ", leading: overlap_leading)
197 | |> TextBlock.write("changes")
198 | |> TextBlock.write("\nthis overlaps")
199 |
200 | assert [
201 | ^expected_final_leading,
202 | ^overlap_leading,
203 | ^heading_leading
204 | ] =
205 | Enum.map(text_block.lines, & &1.leading)
206 |
207 | text_block
208 | end)
209 | end
210 |
211 | test "is set correctly for linebreaks inside writes" do
212 | output(fn %{fonts: fonts} ->
213 | text_block =
214 | TextBlock.new(
215 | align: :left,
216 | font: fonts.regular,
217 | font_size: 80,
218 | position: {0, 500}
219 | )
220 | |> TextBlock.write("Warning!\n", font_size: 140, leading: 20)
221 | |> TextBlock.write("Steps under\nconstruction", leading: 70)
222 |
223 | assert [70, 70, 20] = Enum.map(text_block.lines, & &1.leading)
224 |
225 | text_block
226 | end)
227 | end
228 |
229 | test "can be set per line" do
230 | block =
231 | TextBlock.new(font: @font, font_size: 10)
232 | |> TextBlock.write("this is 14\n", leading: 14)
233 | |> TextBlock.write("this is 12")
234 |
235 | assert [
236 | %Line{leading: 12.0},
237 | %Line{leading: 14}
238 | ] = block.lines
239 | end
240 | end
241 |
242 | describe "left-aligned" do
243 | test "newlines are T*s without text" do
244 | assert [
245 | "BT",
246 | "/F1 10 Tf",
247 | "14 TL",
248 | "400 500 Td",
249 | "0 0 0 rg",
250 | "[ <014C> <010F> 12 <0116> <011D> <01B7> <00ED> <00D9> <00F4> 8 <00C0> ] TJ",
251 | "T*",
252 | "T*",
253 | "[ <0116> 24 <00C0> <00B5> <00FC> <00F4> <00BB> <01B7> <00ED> <00D9> <00F4> 8 <00C0> ] TJ",
254 | "T*",
255 | "ET"
256 | ] =
257 | output(fn %{fonts: fonts} ->
258 | TextBlock.new(
259 | font: fonts.regular,
260 | font_size: 10,
261 | position: {400, 500},
262 | leading: 14
263 | )
264 | |> TextBlock.write("""
265 | first line
266 |
267 | second line
268 | """)
269 | end)
270 | |> operations()
271 | end
272 |
273 | test "inline colours are written with rgs" do
274 | assert [
275 | "BT",
276 | "/F1 10 Tf",
277 | "12.0 TL",
278 | "400 500 Td",
279 | "0 0 0 rg",
280 | _,
281 | "1 0 0 rg",
282 | _,
283 | "T*",
284 | "0 1 0 rg",
285 | _,
286 | "T*",
287 | _,
288 | "0 0 1 rg",
289 | _,
290 | "ET"
291 | ] =
292 | output(fn %{fonts: fonts} ->
293 | TextBlock.new(
294 | font: fonts.regular,
295 | font_size: 10,
296 | position: {400, 500}
297 | )
298 | |> TextBlock.write("a")
299 | |> TextBlock.write("b", colour: {1, 0, 0})
300 | |> TextBlock.write("\nc\nd", colour: {0, 1, 0})
301 | |> TextBlock.write("e", colour: {0, 0, 1})
302 | end)
303 | |> operations()
304 | end
305 |
306 | test "inline font change is written with Tfs" do
307 | assert [
308 | "BT",
309 | "/F1 10 Tf",
310 | "12.0 TL",
311 | "400 500 Td",
312 | "0 0 0 rg",
313 | "/F1 14 Tf",
314 | _,
315 | "/F1 10 Tf",
316 | "/F2 10 Tf",
317 | _,
318 | "/F1 10 Tf",
319 | _,
320 | "/F3 10 Tf",
321 | _,
322 | "/F1 10 Tf",
323 | "ET"
324 | ] =
325 | output(fn %{fonts: fonts} ->
326 | TextBlock.new(
327 | font: fonts.regular,
328 | font_size: 10,
329 | position: {400, 500}
330 | )
331 | |> TextBlock.write("this is ", font_size: 14)
332 | |> TextBlock.write("bold ", font: fonts.bold)
333 | |> TextBlock.write("but this isn't ")
334 | |> TextBlock.write("this is franklin", font: fonts.franklin_regular)
335 | end)
336 | |> operations()
337 | end
338 |
339 | test "inline leading is written with TL, before T* that changes matrix" do
340 | assert [
341 | "BT",
342 | "/F1 10 Tf",
343 | "12.0 TL",
344 | "400 500 Td",
345 | "0 0 0 rg",
346 | "[ <011D> -12 <00D5> <00D9> <0116> <01B7> <00D9> <0116> <01B7> <0155> 40 <0158> ] TJ",
347 | "14 TL",
348 | "T*",
349 | "[ <011D> -12 <00D5> <00D9> <0116> <01B7> <00D9> <0116> <01B7> <0155> -20 <0156> ] TJ",
350 | "12.0 TL",
351 | "ET"
352 | ] =
353 | output(fn %{fonts: fonts} ->
354 | TextBlock.new(
355 | font: fonts.regular,
356 | font_size: 10,
357 | position: {400, 500}
358 | )
359 | |> TextBlock.write("this is 14\n", leading: 14)
360 | |> TextBlock.write("this is 12")
361 | end)
362 | |> operations()
363 | end
364 |
365 | test "underlines happen" do
366 | assert [
367 | "q",
368 | "0.0 470.0 m",
369 | "0 0 0 RG",
370 | "1 w",
371 | "91.344 470.0 l",
372 | "S",
373 | "Q",
374 | "q",
375 | "0.0 498.8 m",
376 | "1 0 0 RG",
377 | "0.6 w",
378 | "61.967999999999996 498.8 l",
379 | "S",
380 | "Q",
381 | "BT"
382 | | _
383 | ] =
384 | output(fn %{fonts: fonts} ->
385 | TextBlock.new(font: fonts.regular, position: {0, 500})
386 | |> TextBlock.write("underlined ", underline: [width: 0.6, colour: {1, 0, 0}])
387 | |> TextBlock.write("\nnot underlined ")
388 | |> TextBlock.write("\nunderlined again", underline: [width: 1])
389 | end)
390 | |> operations()
391 | end
392 | end
393 |
394 | describe "right-aligned" do
395 | test "newlines become T*s with offsets in Tds" do
396 | assert [
397 | "BT",
398 | "/F1 10 Tf",
399 | "12.0 TL",
400 | "400 500 Td",
401 | "-15.38 0 Td",
402 | "0 0 0 rg",
403 | "[ <00A5> ] TJ",
404 | "1 0 0 rg",
405 | "[ <00A5> -20 <00A5> ] TJ",
406 | "15.38 0 Td",
407 | "-20.14 0 Td",
408 | "T*",
409 | "0 0 0 rg",
410 | "[ <0138> 44 <0138> <0138> ] TJ",
411 | "20.14 0 Td",
412 | "-83.38000000000001 0 Td",
413 | "T*",
414 | "[ <0088> 48 <0055> <0088> ] TJ",
415 | "0 1 0 rg",
416 | "[ <0088> 48 <0055> <0088> 68 <0055> <0088> 68 <0055> <0088> ] TJ",
417 | "83.38000000000001 0 Td",
418 | "-0.0 0 Td",
419 | "T*",
420 | "0.0 0 Td",
421 | "-9.26 0 Td",
422 | "T*",
423 | "0 0 0 rg",
424 | "[ <00D5> <00D9> ] TJ",
425 | "9.26 0 Td",
426 | "ET"
427 | ] =
428 | output(fn %{fonts: fonts} ->
429 | TextBlock.new(
430 | font: fonts.regular,
431 | font_size: 10,
432 | position: {400, 500},
433 | align: :right
434 | )
435 | |> TextBlock.write("a")
436 | |> TextBlock.write(
437 | """
438 | aa
439 | """,
440 | colour: {1, 0, 0}
441 | )
442 | |> TextBlock.write("""
443 | www
444 | WOW\
445 | """)
446 | |> TextBlock.write(
447 | """
448 | WOWOWOW
449 | """,
450 | colour: {0, 1, 0}
451 | )
452 | |> TextBlock.write("""
453 |
454 | hi\
455 | """)
456 | end)
457 | |> operations()
458 | end
459 |
460 | test "inline font change is written with Tfs" do
461 | assert [
462 | "BT",
463 | "/F1 10 Tf",
464 | "12.0 TL",
465 | "400 500 Td",
466 | _,
467 | "0 0 0 rg",
468 | _,
469 | "/F2 10 Tf",
470 | _,
471 | "/F1 10 Tf",
472 | _,
473 | "/F3 10 Tf",
474 | _,
475 | "/F1 10 Tf",
476 | _,
477 | "ET"
478 | ] =
479 | output(fn %{fonts: fonts} ->
480 | TextBlock.new(
481 | font: fonts.regular,
482 | font_size: 10,
483 | position: {400, 500},
484 | align: :right
485 | )
486 | |> TextBlock.write("this is ")
487 | |> TextBlock.write("bold ", font: fonts.bold)
488 | |> TextBlock.write("but this isn't ")
489 | |> TextBlock.write("this is franklin", font: fonts.franklin_regular)
490 | end)
491 | |> operations()
492 | end
493 |
494 | test "underlines are correctly aligned" do
495 | assert [
496 | "q",
497 | "-173.59199999999998 498.8 m",
498 | "0 0 0 RG",
499 | "1 w",
500 | "-111.624 498.8 l",
501 | "S",
502 | "Q"
503 | | _
504 | ] =
505 | output(fn %{fonts: fonts} ->
506 | TextBlock.new(font: fonts.regular, position: {0, 500}, align: :right)
507 | |> TextBlock.write("not underlined ")
508 | |> TextBlock.write("underlined ", underline: [width: 1])
509 | |> TextBlock.write("not underlined again")
510 | end)
511 | |> operations()
512 | end
513 |
514 | test "inline font sizes affect alignment offset of whole line" do
515 | assert offset_with_partial_font_size(50) < offset_with_partial_font_size(12)
516 | end
517 |
518 | defp offset_with_partial_font_size(font_size) do
519 | operations =
520 | output(fn %{fonts: fonts} ->
521 | TextBlock.new(font: fonts.regular, align: :right)
522 | |> TextBlock.write("this is ")
523 | |> TextBlock.write("one line", font_size: font_size)
524 | end)
525 | |> operations()
526 |
527 | [offset, _y_offset, _operator] =
528 | operations |> Enum.find(&String.ends_with?(&1, "Td")) |> String.split(" ")
529 |
530 | {offset, ""} = Float.parse(offset)
531 |
532 | offset
533 | end
534 | end
535 |
536 | describe "centre-aligned" do
537 | test "offset of a line is half the right-aligned offset" do
538 | text = "aaaa"
539 | size = 12
540 | right_td = first_td(:right, text, size)
541 | centre_td = first_td(:centre, text, size)
542 |
543 | assert centre_td.tx == right_td.tx / 2
544 | end
545 | end
546 |
547 | defp first_td(align, text, size) do
548 | output(fn %{fonts: fonts} ->
549 | TextBlock.new(align: align, font: fonts.regular, font_size: size)
550 | |> TextBlock.write(text)
551 | end)
552 | |> Enum.find(&match?(%Mudbrick.ContentStream.Td{}, &1))
553 | end
554 |
555 | defp output(f) do
556 | Mudbrick.TestHelper.wrapped_output(f, TextBlock.Output) |> Enum.reverse()
557 | end
558 |
559 | defp operations(ops) do
560 | Enum.map(ops, &Mudbrick.TestHelper.show/1)
561 | end
562 | end
563 |
--------------------------------------------------------------------------------
/test/mudbrick_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MudbrickTest do
2 | use ExUnit.Case, async: true
3 | doctest Mudbrick
4 |
5 | import Mudbrick
6 | import Mudbrick.TestHelper
7 |
8 | test "can serialise with one empty page" do
9 | assert new()
10 | |> page()
11 | |> comparable() ==
12 | """
13 | %PDF-2.0
14 | %����
15 | 1 0 obj
16 | <>
20 | stream
21 |
22 |
23 |
24 |
25 | Mudbrick
26 |
27 |
28 | Mudbrick
29 |
30 |
31 | application/pdf
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | 0000000000000000000000000000000000000000000
40 | 0000000000000000000000000000000000000000000
41 |
42 |
43 |
44 |
45 |
46 | endstream
47 | endobj
48 | 2 0 obj
49 | <>
54 | /XObject <<
55 | >>
56 | >>
57 | >>
58 | endobj
59 | 3 0 obj
60 | <>
64 | endobj
65 | 4 0 obj
66 | <>
71 | endobj
72 | 5 0 obj
73 | <>
75 | stream
76 | endstream
77 | endobj
78 | xref
79 | 0 6
80 | 0000000000 65535 f
81 | 0000000023 00000 n
82 | 0000001151 00000 n
83 | 0000001258 00000 n
84 | 0000001326 00000 n
85 | 0000001422 00000 n
86 | trailer
87 | <>
90 | startxref
91 | 1469
92 | %%EOF\
93 | """
94 | end
95 |
96 | test "can serialise with no pages" do
97 | assert new()
98 | |> comparable() ==
99 | """
100 | %PDF-2.0
101 | %����
102 | 1 0 obj
103 | <>
107 | stream
108 |
109 |
110 |
111 |
112 | Mudbrick
113 |
114 |
115 | Mudbrick
116 |
117 |
118 | application/pdf
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | 0000000000000000000000000000000000000000000
127 | 0000000000000000000000000000000000000000000
128 |
129 |
130 |
131 |
132 |
133 | endstream
134 | endobj
135 | 2 0 obj
136 | <>
141 | /XObject <<
142 | >>
143 | >>
144 | >>
145 | endobj
146 | 3 0 obj
147 | <>
151 | endobj
152 | xref
153 | 0 4
154 | 0000000000 65535 f
155 | 0000000023 00000 n
156 | 0000001151 00000 n
157 | 0000001253 00000 n
158 | trailer
159 | <>
162 | startxref
163 | 1321
164 | %%EOF\
165 | """
166 | end
167 |
168 | def comparable(doc) do
169 | doc
170 | |> render()
171 | |> to_string()
172 | |> String.replace(
173 | ~r{.*},
174 | "0000000000000000000000000000000000000000000"
175 | )
176 | end
177 | end
178 |
--------------------------------------------------------------------------------
/test/predicates_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.PredicatesTest do
2 | use ExUnit.Case, async: true
3 | doctest Mudbrick.Predicates
4 | end
5 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.TestHelper do
2 | @bodoni_regular System.fetch_env!("FONT_LIBRE_BODONI_REGULAR") |> File.read!()
3 | @bodoni_bold System.fetch_env!("FONT_LIBRE_BODONI_BOLD") |> File.read!()
4 | @franklin_regular System.fetch_env!("FONT_LIBRE_FRANKLIN_REGULAR") |> File.read!()
5 | @flower Path.join(__DIR__, "fixtures/JPEG_example_flower.jpg") |> File.read!()
6 | @example_png Path.join(__DIR__, "fixtures/Example.png") |> File.read!()
7 |
8 | use ExUnitProperties
9 | alias Mudbrick.Page
10 |
11 | def invalid_colour do
12 | import StreamData
13 |
14 | valid_component = float(min: 0, max: 1)
15 |
16 | {integer(0..2), float(min: 1.00001), list_of(valid_component, length: 2)}
17 | |> bind(fn {insertion_point, invalid_component, initial_list} ->
18 | initial_list
19 | |> List.insert_at(insertion_point, invalid_component)
20 | |> List.to_tuple()
21 | |> constant()
22 | end)
23 | end
24 |
25 | def show(o) do
26 | Mudbrick.Object.to_iodata(o) |> to_string()
27 | end
28 |
29 | def operations({_doc, content_stream}) do
30 | content_stream.value.operations
31 | |> Enum.reverse()
32 | |> Enum.map(&show/1)
33 | end
34 |
35 | def operations(doc) do
36 | content_stream = Mudbrick.Document.find_object(doc, &match?(%Mudbrick.ContentStream{}, &1))
37 | operations({doc, content_stream})
38 | end
39 |
40 | def bodoni_regular do
41 | @bodoni_regular
42 | end
43 |
44 | def bodoni_bold do
45 | @bodoni_bold
46 | end
47 |
48 | def franklin_regular do
49 | @franklin_regular
50 | end
51 |
52 | def flower do
53 | @flower
54 | end
55 |
56 | def example_png do
57 | @example_png
58 | end
59 |
60 | def wrapped_output(f, output_mod) when is_function(f) do
61 | import Mudbrick
62 |
63 | {doc, _contents_obj} =
64 | context =
65 | Mudbrick.new(
66 | title: "My thing",
67 | compress: false,
68 | fonts: %{
69 | a: bodoni_regular(),
70 | b: bodoni_bold(),
71 | c: franklin_regular()
72 | }
73 | )
74 | |> page(size: Page.size(:letter))
75 |
76 | fonts = Mudbrick.Document.root_page_tree(doc).value.fonts
77 |
78 | block =
79 | f.(%{
80 | fonts: %{
81 | regular: Map.fetch!(fonts, :a).value,
82 | bold: Map.fetch!(fonts, :b).value,
83 | franklin_regular: Map.fetch!(fonts, :c).value
84 | }
85 | })
86 |
87 | ops = output_mod.to_iodata(block).operations
88 |
89 | context
90 | |> Mudbrick.ContentStream.put(operations: ops)
91 | |> render()
92 | |> output()
93 |
94 | ops
95 | end
96 |
97 | def output(chain) do
98 | tap(chain, fn rendered ->
99 | File.write("test.pdf", rendered)
100 | end)
101 | end
102 |
103 | def fonts do
104 | optional_map(%{
105 | bodoni_bold: constant(bodoni_bold()),
106 | bodoni_regular: constant(bodoni_regular()),
107 | franklin_regular: constant(franklin_regular())
108 | })
109 | end
110 |
111 | def images do
112 | optional_map(%{
113 | flower: constant(flower())
114 | })
115 | end
116 |
117 | def datetime do
118 | gen all i <- integer(0..143_256_036_886_856) do
119 | DateTime.from_unix!(i, 1024)
120 | end
121 | end
122 |
123 | def metadata_option do
124 | gen all producer <- string(:alphanumeric),
125 | creator_tool <- string(:alphanumeric),
126 | create_date <- datetime(),
127 | modify_date <- datetime(),
128 | title <- string(:alphanumeric),
129 | creators <- list_of(string(:alphanumeric)),
130 | option <-
131 | member_of(
132 | producer: producer,
133 | creator_tool: creator_tool,
134 | create_date: create_date,
135 | modify_date: modify_date,
136 | title: title,
137 | creators: creators
138 | ) do
139 | option
140 | end
141 | end
142 |
143 | def metadata_options do
144 | gen all options <- list_of(metadata_option()) do
145 | options |> Map.new() |> Enum.into([])
146 | end
147 | end
148 |
149 | def document_option do
150 | gen all fonts <- fonts(),
151 | images <- images(),
152 | compress <- boolean(),
153 | option <-
154 | one_of([
155 | member_of(
156 | compress: compress,
157 | fonts: fonts,
158 | images: images
159 | ),
160 | metadata_option()
161 | ]) do
162 | option
163 | end
164 | end
165 |
166 | def document_options do
167 | gen all options <- list_of(document_option()) do
168 | options |> Map.new() |> Enum.into([])
169 | end
170 | end
171 |
172 | def page_options do
173 | optional_map(%{size: non_negative_coords()})
174 | |> map(&Map.to_list/1)
175 | end
176 |
177 | def image_options do
178 | optional_map(%{
179 | position: coords(),
180 | scale: scale(),
181 | skew: coords()
182 | })
183 | |> map(&Map.to_list/1)
184 | end
185 |
186 | def coords do
187 | {float_non_exponential(), float_non_exponential()}
188 | end
189 |
190 | def non_negative_coords do
191 | {float(min: 0, max: 999), float(min: 0, max: 999)}
192 | end
193 |
194 | def scale do
195 | one_of([
196 | {:auto, float_non_exponential()},
197 | {float_non_exponential(), :auto},
198 | coords()
199 | ])
200 | end
201 |
202 | defp float_non_exponential do
203 | float(min: -999, max: 999)
204 | end
205 | end
206 |
207 | ExUnit.start()
208 |
--------------------------------------------------------------------------------
/test/text_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mudbrick.TextTest do
2 | use ExUnit.Case, async: true
3 | use ExUnitProperties
4 |
5 | import Mudbrick
6 | import Mudbrick.TestHelper
7 |
8 | alias Mudbrick.ContentStream.TJ
9 | alias Mudbrick.Font
10 |
11 | test "with more than one registered font, it's an error not to choose one" do
12 | chain =
13 | new(
14 | compress: false,
15 | fonts: %{regular: bodoni_regular(), bold: bodoni_bold()}
16 | )
17 | |> page()
18 |
19 | assert_raise Font.MustBeChosen, fn ->
20 | text(chain, {"CO₂ ", colour: {1, 0, 0}},
21 | font_size: 14,
22 | align: :right,
23 | position: {200, 700}
24 | )
25 | end
26 | end
27 |
28 | test "parts inherit fonts" do
29 | assert [
30 | "BT",
31 | "/F1 14 Tf",
32 | "16.8 TL",
33 | "200 700 Td",
34 | "-28.294 0 Td",
35 | "1 0 0 rg",
36 | "[ <0011> 4 <0055> <0174> <01B7> ] TJ",
37 | "28.294 0 Td",
38 | "ET"
39 | ] =
40 | new(
41 | compress: false,
42 | fonts: %{bodoni: bodoni_regular()}
43 | )
44 | |> page()
45 | |> text({"CO₂ ", colour: {1, 0, 0}},
46 | font_size: 14,
47 | align: :right,
48 | position: {200, 700}
49 | )
50 | |> operations()
51 | end
52 |
53 | describe "with compression enabled" do
54 | test "compresses text stream" do
55 | text = "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"
56 |
57 | {_doc, compressed_content_stream} =
58 | new(fonts: %{bodoni: bodoni_regular()}, compress: true)
59 | |> page()
60 | |> text(text, font_size: 10)
61 |
62 | {_doc, uncompressed_content_stream} =
63 | new(fonts: %{bodoni: bodoni_regular()}, compress: false)
64 | |> page()
65 | |> text(text, font_size: 10)
66 |
67 | assert IO.iodata_length(Mudbrick.Object.to_iodata(compressed_content_stream)) <
68 | IO.iodata_length(Mudbrick.Object.to_iodata(uncompressed_content_stream))
69 | end
70 | end
71 |
72 | test "can set leading" do
73 | assert [
74 | "BT",
75 | "/F1 10 Tf",
76 | "14 TL",
77 | "0 0 0 rg",
78 | _,
79 | "ET"
80 | ] =
81 | new(fonts: %{bodoni: bodoni_regular()})
82 | |> page()
83 | |> text(
84 | "hello there",
85 | font_size: 10,
86 | leading: 14
87 | )
88 | |> operations()
89 | end
90 |
91 | describe "colour" do
92 | property "it's an error to set a colour above 1" do
93 | check all colour <- invalid_colour() do
94 | e =
95 | assert_raise(Mudbrick.ContentStream.InvalidColour, fn ->
96 | new(fonts: %{my_bodoni: Mudbrick.TestHelper.bodoni_regular()})
97 | |> page()
98 | |> text({"hi there", colour: colour})
99 | end)
100 |
101 | assert e.message == "tuple must be made of floats or integers between 0 and 1"
102 | end
103 | end
104 |
105 | test "can be set on a text block" do
106 | assert [
107 | "BT",
108 | "/F1 10 Tf",
109 | "12.0 TL",
110 | "1.0 0.0 0.0 rg",
111 | _,
112 | _,
113 | "T*",
114 | _,
115 | "ET"
116 | ] =
117 | new(fonts: %{bodoni: bodoni_regular()})
118 | |> page()
119 | |> text(
120 | [
121 | "this is all ",
122 | """
123 | red
124 | text\
125 | """
126 | ],
127 | font_size: 10,
128 | colour: {1.0, 0.0, 0.0}
129 | )
130 | |> operations()
131 | end
132 |
133 | test "can be set on part of a text block" do
134 | assert [
135 | "BT",
136 | "/F1 10 Tf",
137 | "12.0 TL",
138 | "0 0 0 rg",
139 | _,
140 | "1.0 0.0 0.0 rg",
141 | _,
142 | "T*",
143 | _,
144 | "ET"
145 | ] =
146 | new(fonts: %{bodoni: bodoni_regular()})
147 | |> page()
148 | |> text(
149 | [
150 | "black and ",
151 | {"""
152 | red
153 | text\
154 | """, colour: {1.0, 0.0, 0.0}}
155 | ],
156 | font_size: 10
157 | )
158 | |> operations()
159 | end
160 | end
161 |
162 | test "linebreaks are converted to the T* operator" do
163 | assert [
164 | _,
165 | "T*"
166 | | _
167 | ] =
168 | new(fonts: %{bodoni: bodoni_regular()})
169 | |> page(size: :letter)
170 | |> text(
171 | """
172 | a
173 | b\
174 | """,
175 | font_size: 10,
176 | position: {0, 700}
177 | )
178 | |> operations()
179 | |> Enum.take(-4)
180 | end
181 |
182 | test "kerned text is assigned to the operator struct" do
183 | {_doc, content_stream} =
184 | new(fonts: %{bodoni: bodoni_regular()})
185 | |> page(size: :letter)
186 | |> text("CO₂", font_size: 24, position: {0, 700})
187 |
188 | [_et, show_text_operation | _] = content_stream.value.operations
189 |
190 | assert %TJ{
191 | kerned_text: [{"0011", 4}, "0055", "0174"]
192 | } = show_text_operation
193 | end
194 |
195 | describe "serialisation" do
196 | test "converts TJ text to the assigned font's glyph IDs in hex, with kerning" do
197 | assert ["[ <0011> 4 <0055> <0174> ] TJ", "ET"] =
198 | new(fonts: %{bodoni: bodoni_regular()})
199 | |> page()
200 | |> text("CO₂", font_size: 24, position: {0, 700})
201 | |> operations()
202 | |> Enum.take(-2)
203 | end
204 |
205 | test "with auto-kerning disabled, doesn't write kerns" do
206 | assert ["[ <0011> <0055> <0174> ] TJ", "ET"] =
207 | new(fonts: %{bodoni: bodoni_regular()})
208 | |> page()
209 | |> text([{"\n", auto_kern: true}, {"CO₂", auto_kern: false}],
210 | font_size: 24,
211 | position: {0, 700}
212 | )
213 | |> operations()
214 | |> Enum.take(-2)
215 | end
216 |
217 | test "copes with trailing newlines" do
218 | assert new(fonts: %{bodoni: bodoni_regular()})
219 | |> page()
220 | |> text("\n", font_size: 13)
221 | |> render()
222 | end
223 | end
224 | end
225 |
--------------------------------------------------------------------------------