├── .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 | --------------------------------------------------------------------------------