├── .gitignore ├── .travis.yml ├── test ├── test_helper.exs ├── support │ ├── images │ │ └── alpaca.png │ └── templates │ │ └── cucumber_salads.pdf ├── gutenex │ ├── geometry_test.exs │ ├── pdf │ │ ├── templates_test.exs │ │ ├── graphics_test.exs │ │ ├── font_test.exs │ │ ├── images_test.exs │ │ ├── builders │ │ │ ├── catalog_builder_test.exs │ │ │ ├── image_builder_test.exs │ │ │ ├── font_builder_test.exs │ │ │ ├── template_builder_test.exs │ │ │ ├── meta_data_builder_test.exs │ │ │ ├── page_builder_test.exs │ │ │ └── page_tree_builder_test.exs │ │ ├── text_test.exs │ │ ├── serialization_test.exs │ │ ├── render_context_test.exs │ │ └── page_test.exs │ ├── pdf_test.exs │ └── geometry │ │ ├── bezier_test.exs │ │ └── line_test.exs └── gutenex_test.exs ├── lib ├── gutenex │ ├── geometry │ │ ├── bezier.ex │ │ ├── line.ex │ │ └── rectangle.ex │ ├── geometry.ex │ ├── pdf │ │ ├── templates.ex │ │ ├── graphics.ex │ │ ├── context.ex │ │ ├── utils.ex │ │ ├── builder.ex │ │ ├── builders │ │ │ ├── catalog_builder.ex │ │ │ ├── font_builder.ex │ │ │ ├── meta_data_builder.ex │ │ │ ├── page_tree_builder.ex │ │ │ ├── template_builder.ex │ │ │ ├── page_builder.ex │ │ │ └── image_builder.ex │ │ ├── images.ex │ │ ├── exporter.ex │ │ ├── render_context.ex │ │ ├── text.ex │ │ ├── font.ex │ │ ├── page │ │ │ └── page_sizes.ex │ │ ├── serialization.ex │ │ └── page.ex │ └── pdf.ex └── gutenex.ex ├── mix.lock ├── mix.exs ├── FIXME.md ├── config └── config.exs ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | tmp/ 6 | docs/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | otp_release: 3 | - 17.0 4 | env: 5 | - MIX_ENV=test 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | ExUnit.configure exclude: [integration: true] 3 | -------------------------------------------------------------------------------- /test/support/images/alpaca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edebill/gutenex/master/test/support/images/alpaca.png -------------------------------------------------------------------------------- /lib/gutenex/geometry/bezier.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Geometry.Bezier do 2 | import Gutenex.Geometry 3 | 4 | end 5 | -------------------------------------------------------------------------------- /test/support/templates/cucumber_salads.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edebill/gutenex/master/test/support/templates/cucumber_salads.pdf -------------------------------------------------------------------------------- /lib/gutenex/geometry.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Geometry do 2 | def move_to({point_x, point_y}) do 3 | "#{point_x} #{point_y} m\n" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/gutenex/geometry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.GeometryTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.Geometry 4 | 5 | test "#move_to should move to a point" do 6 | assert Geometry.move_to({20, 40}) == "20 40 m\n" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/templates.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Templates do 2 | 3 | def load(path) do 4 | { 5 | template_alias(path), 6 | File.read!(path) 7 | } 8 | end 9 | 10 | def template_alias(template_path) do 11 | Base.hex_encode32(template_path) 12 | |> String.strip(?=) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/graphics.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Graphics do 2 | 3 | def paint(aliaz) do 4 | "/#{aliaz} Do\n" 5 | end 6 | 7 | def save_state do 8 | "q\n" 9 | end 10 | 11 | def restore_state do 12 | "Q\n" 13 | end 14 | 15 | def with_state(fun) do 16 | Enum.join [ 17 | save_state(), 18 | fun.(), 19 | restore_state() 20 | ] 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /test/gutenex/pdf/templates_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.TemplatesTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Templates 4 | 5 | test "#load" do 6 | contents = File.read!("./test/support/templates/cucumber_salads.pdf") 7 | template_alias = Templates.template_alias("./test/support/templates/cucumber_salads.pdf") 8 | assert {template_alias, contents} == 9 | Templates.load("./test/support/templates/cucumber_salads.pdf") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/gutenex/pdf_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDFTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF 4 | 5 | test "#add_page with no current pages" do 6 | context = %Gutenex.PDF.Context{} 7 | current_page_stream = "Stream of Consciousness" 8 | context = PDF.add_page(context, current_page_stream) 9 | 10 | assert context.current_page == 2, "It increments the current page" 11 | assert List.first(context.pages) == current_page_stream, "It adds the content to the #pages" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/gutenex/pdf.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF do 2 | require Record 3 | alias Gutenex.PDF.Context 4 | 5 | def export(%Context{} = context, stream) do 6 | context = add_page(context, stream) 7 | {render_context, ^context} = Gutenex.PDF.Builder.build(context) 8 | Gutenex.PDF.Exporter.export(render_context) 9 | end 10 | 11 | def add_page(%Context{} = context, stream) do 12 | next_page_number = context.current_page + 1 13 | %Context{ context | 14 | current_page: next_page_number, 15 | pages: Enum.reverse([stream | context.pages]) 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/gutenex/pdf/graphics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.GraphicsTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Graphics 4 | 5 | test "#save_state" do 6 | assert Graphics.save_state() == "q\n" 7 | end 8 | 9 | test "#restore_state" do 10 | assert Graphics.restore_state() == "Q\n" 11 | end 12 | 13 | test "#with_state" do 14 | assert Graphics.with_state(fn -> 15 | "Bubbles!" 16 | end) == "#{Graphics.save_state()}Bubbles!#{Graphics.restore_state()}" 17 | end 18 | 19 | test "#paint" do 20 | assert Graphics.paint("Banananas") == "/Banananas Do\n" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/gutenex/pdf/font_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.FontTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Font 4 | 5 | test "setting a font" do 6 | assert Font.set_font(Font.standard_fonts(), "Courier", 32) == "/Courier 32 Tf\n", 7 | "it returns the font set command" 8 | 9 | assert Font.set_font(Font.standard_fonts(), "Helvetica") == "/Helvetica 12 Tf\n", 10 | "it defaults the size to 12" 11 | 12 | assert Font.set_font(Font.standard_fonts(), "Bananas") == "/Helvetica 12 Tf\n", 13 | "it defaults the font to Helvetica when font is not found" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Context do 2 | alias Gutenex.PDF.Page 3 | defstruct( 4 | meta_data: %{ 5 | creator: "Elixir", 6 | creation_date: :calendar.local_time(), 7 | producer: "Gutenex", 8 | author: "", 9 | title: "", 10 | subject: "", 11 | keywords: "" 12 | }, 13 | images: %{}, 14 | fonts: Gutenex.PDF.Font.standard_fonts(), 15 | templates: [nil], 16 | template_aliases: %{}, 17 | pages: [], 18 | scripts: [], 19 | convert_mode: "utf8_to_latin2", 20 | current_page: 1, 21 | media_box: Page.page_size(:letter), 22 | generation_number: 0) 23 | end 24 | -------------------------------------------------------------------------------- /test/gutenex/geometry/bezier_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Geometry.BezierText do 2 | use ExUnit.Case, async: true 3 | 4 | # test "#bezier" 5 | end 6 | 7 | 8 | # bezier(X1, Y1, X2, Y2, X3, Y3, X4, Y4)-> 9 | # bezier({X1, Y1}, {X2, Y2}, {X3, Y3}, {X4, Y4}). 10 | 11 | # bezier(Point1, Point2, Point3, Point4)-> 12 | # [move_to(Point1), bezier_c( Point2, Point3, Point4 ) ]. 13 | 14 | # bezier_c({X1, Y1}, {X2, Y2}, {X3, Y3})-> 15 | # [n2s([X1, Y1, X2, Y2, X3, Y3]), " c\n"]. 16 | 17 | # bezier_v({X2, Y2}, {X3, Y3})-> 18 | # [n2s([X2, Y2, X3, Y3]), " v\n"]. 19 | 20 | # bezier_y({X1, Y1}, {X3, Y3})-> 21 | # [n2s([X1, Y1, X3, Y3]), " y\n"]. 22 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Utils do 2 | 3 | # Base case, escaping the empty string is the empty string 4 | def escape(<<>>) do 5 | <<>> 6 | end 7 | 8 | # escape left parenthesis 9 | def escape(<<"(", rest::binary>>) do 10 | "\\(" <> escape(rest) 11 | end 12 | 13 | # escape right parenthesis 14 | def escape(<<")", rest::binary>>) do 15 | "\\)" <> escape(rest) 16 | end 17 | 18 | # escape backslash 19 | def escape(<<"\\", rest::binary>>) do 20 | "\\\\" <> escape(rest) 21 | end 22 | 23 | # Head doesn't need escaping, escape the rest 24 | def escape(<>) do 25 | first_byte <> escape(rest) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"apex": {:hex, :apex, "0.3.7", "0e4b39ff34d740da9898e368995fdaab2fe4d2a444a6bbe23ccffb3c6978b92c", [:mix], []}, 2 | "earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], []}, 3 | "erlguten": {:git, "git://github.com/SenecaSystems/erlguten.git", "c02b8fc57571615837b4b369cef3fecaa609436b", []}, 4 | "ex_doc": {:hex, :ex_doc, "0.11.5", "0dc51cb84f8312162a2313d6c71573a9afa332333d8a332bb12540861b9834db", [:mix], [{:earmark, "~> 0.1.17 or ~> 0.2", [hex: :earmark, optional: true]}]}, 5 | "imagineer": {:hex, :imagineer, "0.2.1", "b426707d1c8326cab81ce4d3f5e029283776a7b806db9dbc51306eb2e1451aa6", [:mix], [{:apex, "~>0.3.2", [hex: :apex, optional: false]}]}} 6 | -------------------------------------------------------------------------------- /lib/gutenex/geometry/line.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Geometry.Line do 2 | import Gutenex.Geometry 3 | 4 | def line_width(number) do 5 | "#{number} w q " 6 | end 7 | 8 | def line({from_point, to_point}) do 9 | move_to(from_point) <> 10 | draw_line(to_point) <> 11 | "S\n" 12 | end 13 | 14 | def line({from_x, from_y}, {to_x, to_y}) do 15 | line({{from_x, from_y}, {to_x, to_y}}) 16 | end 17 | 18 | def line(from_x, from_y, to_x, to_y) do 19 | line({{from_x, from_y}, {to_x, to_y}}) 20 | end 21 | 22 | def lines(list_of_lines) do 23 | Enum.map(list_of_lines, &line(&1)) 24 | end 25 | 26 | defp draw_line({point_x, point_y}) do 27 | "#{point_x} #{point_y} l " 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/gutenex/geometry/line_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Geometry.LineTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.Geometry.Line 4 | 5 | test "#line should move to the start point and draw to the end" do 6 | assert Line.line({20, 30}, {60, 90}) == "20 30 m\n60 90 l S\n" 7 | end 8 | 9 | test "#line should work with floats and all points passed in" do 10 | assert Line.line(20.798231, 30.7213, 60.1233, 90.678123) == 11 | "20.798231 30.7213 m\n60.1233 90.678123 l S\n" 12 | end 13 | 14 | test "#lines returns a list of lines" do 15 | lines = Line.lines([{{10, 20}, {30, 40}}, {{50, 60}, {70, 80}}, {{90, 100}, {110, 120}}]) 16 | assert lines == [ 17 | "10 20 m\n30 40 l S\n", 18 | "50 60 m\n70 80 l S\n", 19 | "90 100 m\n110 120 l S\n" 20 | ] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | alias Gutenex.PDF.Builders.ImageBuilder 5 | alias Gutenex.PDF.Builders.FontBuilder 6 | alias Gutenex.PDF.Builders.PageBuilder 7 | alias Gutenex.PDF.Builders.PageTreeBuilder 8 | alias Gutenex.PDF.Builders.CatalogBuilder 9 | alias Gutenex.PDF.Builders.MetaDataBuilder 10 | 11 | # The way I'm building this looks suspiciously like a GenServer... 12 | @doc """ 13 | Takes in a PDF.Context and returns a PDF.RenderContext 14 | """ 15 | def build(%Context{}=context) do 16 | {_render_context, ^context} = {%RenderContext{}, context} 17 | |> ImageBuilder.build 18 | |> FontBuilder.build 19 | |> PageTreeBuilder.build 20 | |> PageBuilder.build 21 | |> CatalogBuilder.build 22 | |> MetaDataBuilder.build 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :gutenex, 7 | name: "Gutenex", 8 | version: "0.2.0", 9 | source_url: "https://github.com/SenecaSystems/gutenex", 10 | elixir: "~> 1.0", 11 | deps: deps, 12 | description: description, 13 | package: package 14 | ] 15 | end 16 | 17 | def application do 18 | [applications: [:logger]] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:imagineer, "~> 0.1" }, 24 | {:earmark, "~> 0.1", only: :dev}, 25 | {:ex_doc, "~> 0.6", only: :dev } 26 | ] 27 | end 28 | 29 | defp description do 30 | """ 31 | PDF Generation in Elixir 32 | """ 33 | end 34 | 35 | defp package do 36 | [ 37 | licenses: ["MIT"], 38 | links: %{github: "https://github.com/SenecaSystems/gutenex"}, 39 | contributors: ["Chris Maddox"] 40 | ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /FIXME.md: -------------------------------------------------------------------------------- 1 | 1. Don't include every basic font object by default, only as used 2 | ```elixir 3 | %Context{used_fonts: HashSet.new()} 4 | ``` 5 | 2. Add more natural text output syntax. Instead of 6 | ```elixir 7 | {:ok, pid} = Gutenex.start_link 8 | Gutenex.begin_text(pid) 9 | |> Gutenex.set_font("Helvetica", 48) 10 | |> Gutenex.text_position(40, 180) 11 | |> Gutenex.text_render_mode(:fill) 12 | |> Gutenex.write_text("Screen Images Simulated") 13 | |> Gutenex.end_text 14 | ``` 15 | How about: 16 | ```elixir 17 | {:ok, pid} = Gutenex.start_link 18 | Gutenex.text(pid) do 19 | import Gutenex 20 | set_font("Helvetica", 48) 21 | text_position(40, 180) 22 | text_render_mode(:fill) 23 | write_text("Screen Images Simulated") 24 | end 25 | ``` 26 | Or maybe a function: 27 | ```elixir 28 | {:ok, pid} = Gutenex.start_link 29 | text_options = %{position: {40, 180}, font: {"Helvetica", 48}, render_mode: :fill} 30 | Gutenex.text(pid, text_options, fn -> 31 | Gutenex.write_text(pid, "Screen Images Simulated") 32 | end) 33 | ``` 34 | Who knows! 35 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Chris Maddox 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/catalog_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.CatalogBuilder do 2 | alias Gutenex.PDF.RenderContext 3 | alias Gutenex.PDF.Context 4 | 5 | def build({%RenderContext{}=render_context, %Context{}=context}) do 6 | render_context = add_catalog(render_context) 7 | |> add_catalog_reference 8 | |> RenderContext.next_index 9 | {render_context, context} 10 | end 11 | 12 | defp add_catalog(%RenderContext{}=render_context) do 13 | %RenderContext{ 14 | render_context | 15 | catalog: { 16 | RenderContext.current_object(render_context), 17 | {:dict, %{ 18 | "Type" => {:name, "Catalog"}, 19 | "Pages" => render_context.page_tree_reference, 20 | "Names" => name_dictionary(render_context) 21 | }} 22 | } 23 | } 24 | end 25 | 26 | defp add_catalog_reference(render_context) do 27 | %RenderContext{ 28 | render_context | 29 | catalog_reference: RenderContext.current_reference(render_context) 30 | } 31 | end 32 | 33 | defp name_dictionary(render_context) do 34 | { 35 | :dict, 36 | %{ 37 | "Templates" => {:dict, render_context.template_aliases} 38 | } 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/gutenex/pdf/images_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.ImagesTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Images 4 | 5 | test "#set_image" do 6 | aliaz = "Image1000" 7 | image = %Imagineer.Image.PNG{width: 237, height: 191} 8 | assert Images.set_image(aliaz, image) == 9 | "q\n237 0 0 191 0 0 cm\n/#{aliaz} Do\nQ\n" 10 | end 11 | 12 | test "#set_image with a custom width and height" do 13 | aliaz = "Image1000" 14 | image = %Imagineer.Image.PNG{width: 237, height: 191} 15 | assert Images.set_image(aliaz, image, %{width: 100, height: 100}) == 16 | "q\n100 0 0 100 0 0 cm\n/#{aliaz} Do\nQ\n" 17 | end 18 | 19 | test "#set_image with a translation" do 20 | aliaz = "Image1000" 21 | image = %Imagineer.Image.PNG{width: 100, height: 100} 22 | assert Images.set_image(aliaz, image, %{translate_x: 500, translate_y: 300}) == 23 | "q\n100 0 0 100 500 300 cm\n/#{aliaz} Do\nQ\n" 24 | end 25 | 26 | test "#set_image with a skew" do 27 | aliaz = "Image1000" 28 | image = %Imagineer.Image.PNG{width: 100, height: 100} 29 | assert Images.set_image(aliaz, image, %{skew_x: 15, skew_y: 7}) == 30 | "q\n100 15 7 100 0 0 cm\n/#{aliaz} Do\nQ\n" 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/catalog_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.CatalogBuilderTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.RenderContext 4 | alias Gutenex.PDF.Context 5 | alias Gutenex.PDF.Builders.CatalogBuilder 6 | 7 | test "#build" do 8 | render_context = %RenderContext{ 9 | current_index: 7, 10 | generation_number: 13, 11 | page_tree_reference: {:ptr, 12, 0}, 12 | template_aliases: %{"Francis" => {:ptr, 22, 1}} 13 | } 14 | {updated_render_context, _context} = CatalogBuilder.build({render_context, %Context{}}) 15 | assert updated_render_context.catalog_reference == 16 | RenderContext.current_reference(render_context) 17 | assert updated_render_context.catalog == { 18 | RenderContext.current_object(render_context), 19 | {:dict, %{ 20 | "Type" => {:name, "Catalog"}, 21 | "Pages" => render_context.page_tree_reference, 22 | "Names" => { 23 | :dict, 24 | %{ 25 | "Templates" => {:dict, render_context.template_aliases} 26 | } 27 | } 28 | } 29 | } 30 | } 31 | assert updated_render_context.current_index == render_context.current_index + 1 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/image_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.ImageBuilderTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Context 4 | alias Gutenex.PDF.RenderContext 5 | alias Gutenex.PDF.Builders.ImageBuilder 6 | 7 | setup do 8 | {:ok, image} = Imagineer.load("./test/support/images/alpaca.png") 9 | image_x_object = {100, [{{:obj, 100, 30}, {:stream, {:dict, %{"Type" => {:name, "XObject"}, "Subtype" => {:name, "Image"}, "Width" => 96, "Height" => 96, "Filter" => {:name, "FlateDecode"}, "BitsPerComponent" => 8, "DecodeParms" => {:dict, %{"Predictor" => 15, "Colors" => 3, "BitsPerComponent" => 8, "Columns" => 96}}, "ColorSpace" => {:name, "DeviceRGB"}}}, Imagineer.Image.PNG.to_binary(image)}}, []]} 10 | {:ok, %{image: image, image_x_object: image_x_object}} 11 | end 12 | 13 | test "#build", %{image: image, image_x_object: x_object} do 14 | context = %Context{images: %{"Bobby" => image}, generation_number: 30} 15 | {_obj_count, [raw_x_object, []]} = x_object 16 | {new_render_context, ^context} = ImageBuilder.build({%RenderContext{current_index: 100, generation_number: 30}, context}) 17 | assert new_render_context.current_index == 101 18 | assert new_render_context.image_objects == [raw_x_object] 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/gutenex/geometry/rectangle.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.Geometry.Rectangle do 2 | import Gutenex.Geometry 3 | 4 | 5 | # def rectangle({height_x, height_y}, {width_x, width_y})do 6 | # rectangle(height_x, height_y, width_x, width_y) 7 | # end 8 | 9 | # def rectangle({height_x, height_y}, {width_x, width_y}, stroke_type) do 10 | # rectangle(height_x, height_y, width_x, width_y, stroke_type) 11 | # end 12 | 13 | # def rectangle(height_x, height_y, width_x, width_y) do 14 | # [n2s([X,Y,WX,WY])," re "]. 15 | 16 | # def rectangle(x,y,wx,wy,stroketype)do 17 | # [rectangle(X,Y,WX,WY), path(StrokeType) ]. 18 | 19 | 20 | # def round_rect({x,y}, {w, h}, r) do 21 | # [ move_to({X+R,Y}), 22 | # line_append({X+W-R, Y}), 23 | # bezier_c( {X+W-R+R*mpo(), Y}, {X+W, Y+R*mpo()}, {X+W,Y+R} ), 24 | # line_append({X+W,Y+H-R}), 25 | # bezier_c( {X+W, Y+H-R+R*mpo()}, {X+W-R+R*mpo(), Y+H}, {X+W-R, Y+H}), 26 | # line_append({X+R, Y+H}), 27 | # bezier_c( {X+R*mpo(), Y+H}, {X, Y+H-R+R*mpo()}, {X, Y+H-R} ), 28 | # line_append({X, Y+R}), 29 | # bezier_c( {X, Y+R*mpo()}, {X+R*mpo(), Y}, {X+R, Y} ) 30 | # ]. 31 | 32 | # def round_top_rect({x,y}, {w, h}, r) do 33 | # [ move_to({X,Y}), 34 | # line_append({X+W, Y}), 35 | # line_append({X+W,Y+H-R}), 36 | # bezier_c( {X+W, Y+H-R+R*mpo()}, {X+W-R+R*mpo(), Y+H}, {X+W-R, Y+H}), 37 | # line_append({X+R, Y+H}), 38 | # bezier_c( {X+R*mpo(), Y+H}, {X, Y+H-R+R*mpo()}, {X, Y+H-R} ), 39 | # line_append({X, Y}) 40 | # ]. 41 | 42 | end 43 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/font_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.FontBuilder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | 5 | # Builds each font object, adding the font objects and references to the 6 | # render context. Returns {render_context, context} 7 | def build({%RenderContext{}=render_context, %Context{}=context}) do 8 | updated_render_context = build_fonts(render_context, Map.to_list(context.fonts)) 9 | {updated_render_context, context} 10 | end 11 | 12 | defp build_fonts(%RenderContext{}=render_context, []) do 13 | %RenderContext{ 14 | render_context | 15 | font_objects: Enum.reverse(render_context.font_objects) 16 | } 17 | end 18 | 19 | defp build_fonts(%RenderContext{}=render_context, [{font_alias, font_definition} | fonts]) do 20 | render_context = %RenderContext{ 21 | RenderContext.next_index(render_context) | 22 | font_aliases: add_font_alias(render_context, font_alias), 23 | font_objects: add_font_object(render_context, font_definition) 24 | } 25 | build_fonts(render_context, fonts) 26 | end 27 | 28 | defp add_font_alias(render_context, font_alias) do 29 | reference = RenderContext.current_reference(render_context) 30 | Map.put(render_context.font_aliases, font_alias, reference) 31 | end 32 | 33 | defp add_font_object(render_context, font_definition) do 34 | [ 35 | { 36 | RenderContext.current_object(render_context), 37 | {:dict, font_definition} 38 | } 39 | | render_context.font_objects 40 | ] 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/font_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.FontBuilderTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Context 4 | alias Gutenex.PDF.RenderContext 5 | alias Gutenex.PDF.Builders.FontBuilder 6 | 7 | setup do 8 | font_1 = %{"Name" => "Abra", "Subtype" => "Type1", "Type" => "Font"} 9 | font_2 = %{"Name" => "Barbara", "Subtype" => "Type1", "Type" => "Font"} 10 | font_3 = %{"Name" => "Cabana", "Subtype" => "Type1", "Type" => "Font"} 11 | fonts = %{"Abra" => font_1, "Barbara" => font_2, "Cabana" => font_3} 12 | 13 | {render_context, context} = FontBuilder.build({ 14 | %RenderContext{current_index: 100, generation_number: 47}, 15 | %Context{fonts: fonts} 16 | }) 17 | {:ok, %{ context: context, render_context: render_context }} 18 | end 19 | 20 | test "#build increments the current index", %{render_context: render_context} do 21 | assert render_context.current_index == 103 22 | end 23 | 24 | test "#build adds the font references", %{render_context: render_context} do 25 | assert render_context.font_aliases == %{ 26 | "Abra" => {:ptr, 100, 47}, 27 | "Barbara" => {:ptr, 101, 47}, 28 | "Cabana" => {:ptr, 102, 47} 29 | } 30 | end 31 | 32 | test "#build adds the font objects", %{context: context, render_context: render_context} do 33 | assert render_context.font_objects == [ 34 | {{:obj, 100, 47}, {:dict, Map.get(context.fonts, "Abra")}}, 35 | {{:obj, 101, 47}, {:dict, Map.get(context.fonts, "Barbara")}}, 36 | {{:obj, 102, 47}, {:dict, Map.get(context.fonts, "Cabana")}} 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/template_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.TemplateBuilderTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Gutenex.PDF.Context 5 | alias Gutenex.PDF.RenderContext 6 | alias Gutenex.PDF.Builders.TemplateBuilder 7 | 8 | test "#build" do 9 | context = %Context{ 10 | templates: [nil, "Hi", nil], 11 | template_aliases: %{"Hi" => "Bubbles!"} 12 | } 13 | render_context = %RenderContext{ 14 | current_index: 129, 15 | generation_number: 0 16 | } 17 | 18 | {updated_render_context, ^context} = 19 | TemplateBuilder.build({render_context, context}) 20 | 21 | assert updated_render_context.current_index == render_context.current_index + 1 22 | 23 | # It should add the alias 24 | assert updated_render_context.template_aliases == 25 | %{ "Hi" => RenderContext.current_reference(render_context) } 26 | 27 | # It shouldn't build for nil templates 28 | assert length(updated_render_context.template_objects) == 1 29 | [{ 30 | template_object, 31 | template_stream 32 | }] = updated_render_context.template_objects 33 | 34 | assert template_object == RenderContext.current_object(render_context) 35 | 36 | # Test the template stream itself 37 | { 38 | :stream, 39 | { 40 | :dict, 41 | template_dictionary 42 | }, 43 | template_contents 44 | } = template_stream 45 | 46 | assert template_contents == "Bubbles!" 47 | assert Map.get(template_dictionary, "Type") == "XObject" 48 | assert Map.get(template_dictionary, "BBox") == {:array, Tuple.to_list(context.media_box)} 49 | assert Map.get(template_dictionary, "SubType") == "Form" 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/images.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Images do 2 | alias Gutenex.PDF.Graphics 3 | alias Imagineer.Image.PNG 4 | 5 | def set_image(image_alias, %PNG{}=image, options\\%{}) do 6 | Graphics.with_state fn -> 7 | scale(image_options(image, options)) <> 8 | Graphics.paint(image_alias) 9 | end 10 | end 11 | 12 | # So named because `alias` is not something I want to redefine 13 | def image_alias(image) do 14 | :crypto.hash(:md5, (:crypto.rand_bytes(100))) 15 | |> Base.encode16 16 | end 17 | 18 | def load(file_path) do 19 | {:ok, image} = Imagineer.load(file_path) 20 | { 21 | image_alias(image), 22 | image 23 | } 24 | end 25 | 26 | defp image_options(image, options) do 27 | default_options = %{ 28 | width: image.width, 29 | height: image.height, 30 | translate_x: 0, 31 | translate_y: 0, 32 | skew_x: 0, 33 | skew_y: 0 34 | } 35 | Dict.merge(default_options, options) 36 | end 37 | 38 | defp scale(options) do 39 | scale_params = Enum.map([ 40 | Map.get(options, :width), 41 | Map.get(options, :skew_x), 42 | Map.get(options, :skew_y), 43 | Map.get(options, :height), 44 | Map.get(options, :translate_x), 45 | Map.get(options, :translate_y) 46 | ], &to_string/1) 47 | |> Enum.join(" ") 48 | "#{scale_params} cm\n" 49 | end 50 | 51 | def png_color(0), do: "DeviceGray" 52 | def png_color(2), do: "DeviceRGB" 53 | def png_color(3), do: "DeviceGray" 54 | def png_color(4), do: "DeviceGray" 55 | def png_color(6), do: "DeviceRGB" 56 | 57 | def png_bits(0), do: 1 58 | def png_bits(2), do: 3 59 | def png_bits(3), do: 1 60 | def png_bits(4), do: 1 61 | def png_bits(6), do: 3 62 | end 63 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/meta_data_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.MetaDataBuilder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | 5 | @doc """ 6 | Given a PDF Context and index, generate the metadata information dictionary 7 | """ 8 | def build({%RenderContext{}=render_context, %Context{}=context}) do 9 | { 10 | add_meta_data_to_render_context(render_context, context), 11 | context 12 | } 13 | end 14 | 15 | defp add_meta_data_to_render_context(%RenderContext{}=render_context, %Context{}=context) do 16 | set_meta_data_reference(render_context) 17 | |> set_meta_data(context) 18 | end 19 | 20 | defp set_meta_data(%RenderContext{}=render_context, %Context{}=context) do 21 | %RenderContext{ 22 | RenderContext.next_index(render_context) | 23 | meta_data: { 24 | RenderContext.current_object(render_context), 25 | meta_data_dictionary(context) 26 | } 27 | } 28 | end 29 | 30 | defp set_meta_data_reference(%RenderContext{}=render_context) do 31 | %RenderContext{ 32 | render_context | 33 | meta_data_reference: RenderContext.current_reference(render_context) 34 | } 35 | end 36 | 37 | defp meta_data_dictionary(%Context{}=context) do 38 | { 39 | :dict, 40 | %{ 41 | "Title" => {:string, context.meta_data.title}, 42 | "Author" => {:string, context.meta_data.author}, 43 | "Creator" => {:string, context.meta_data.creator}, 44 | "Subject" => {:string, context.meta_data.subject}, 45 | "Producer" => {:string, context.meta_data.producer}, 46 | "Keywords" => {:string, context.meta_data.keywords}, 47 | "CreationDate" => {:date, context.meta_data.creation_date} 48 | } 49 | } 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/exporter.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Exporter do 2 | alias Gutenex.PDF.RenderContext 3 | alias Gutenex.PDF.Serialization 4 | # Declare the PDF version 5 | @start_mark """ 6 | %PDF-1.7 7 | """ 8 | @end_mark "%%EOF\r\n" 9 | 10 | def export(%RenderContext{}=render_context) do 11 | serialized_objects = 12 | Enum.map(RenderContext.objects(render_context), &Serialization.serialize/1) 13 | @start_mark <> 14 | Enum.join(serialized_objects) <> 15 | cross_reference_table(serialized_objects) <> 16 | Serialization.serialize(RenderContext.trailer(render_context)) <> 17 | start_cross_reference(serialized_objects) <> 18 | @end_mark 19 | end 20 | 21 | def trailer(root_index, generation_number, meta_data_index, objects) do 22 | Serialization.serialize({:trailer, {:dict, 23 | %{ 24 | "Size" => length(objects) + 1, 25 | "Root" => {:ptr, root_index, generation_number}, 26 | "Info" => {:ptr, meta_data_index, generation_number} 27 | }}}) 28 | end 29 | 30 | def cross_reference_table(serialized_objects) do 31 | pdf_start_position = String.length(@start_mark) 32 | {xrefs, _acc} = :lists.mapfoldl &xref/2, pdf_start_position, serialized_objects 33 | """ 34 | xref 35 | 0 #{length(serialized_objects) + 1} 36 | #{xref1(0, "65535 f")}#{xrefs} 37 | """ 38 | end 39 | 40 | def start_cross_reference(serialized_objects) do 41 | total_length = Enum.reduce serialized_objects, String.length(@start_mark), fn (object, total) -> 42 | String.length(object) + total 43 | end 44 | 45 | """ 46 | startxref 47 | #{total_length} 48 | """ 49 | end 50 | 51 | def xref(serialized_objects, position) do 52 | objects_length = String.length(serialized_objects) 53 | {xref1(position, "00000 n"), position + objects_length} 54 | end 55 | 56 | def xref1(position, string) do 57 | :io_lib.format("~10.10.0w ~s \n", [position, string]) 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gutenex 2 | 3 | [![Build Status](https://travis-ci.org/SenecaSystems/gutenex.svg?branch=master)](https://travis-ci.org/SenecaSystems/gutenex) 4 | 5 | PDF generation! 6 | 7 | > So weird that it's still a thing for murderers in horror movies to keep clippings of their crimes. PDF that shit! 8 | — [Julieanne Smolinkski](https://twitter.com/BoobsRadley) 9 | 10 | 11 | What started out as a wrapper for the Erlang [erlguten](https://github.com/ztmr/erlguten) library has turned into a full rewrite in Elixir. 12 | 13 | ## Plan 14 | 15 | Rewriting the basic PDF functionality means: 16 | 17 | - [x] text 18 | - [x] fonts 19 | - [x] images 20 | - [x] rendering/exporting 21 | - [ ] parsing existing PDFs 22 | - [ ] templating 23 | - [ ] documentation 24 | 25 | # Usage 26 | 27 | ```elixir 28 | # Load image, get alias 29 | {alpaca_alias, alpaca_rendition} = Gutenex.PDF.Images.load("./test/support/images/alpaca.png") 30 | 31 | {:ok, pid} = Gutenex.start_link 32 | Gutenex.add_image(pid, alpaca_alias, alpaca_rendition) 33 | |> Gutenex.begin_text 34 | |> Gutenex.set_font("Helvetica", 48) 35 | |> Gutenex.text_position(40, 180) 36 | |> Gutenex.text_render_mode(:fill) 37 | |> Gutenex.write_text("ABC") 38 | |> Gutenex.set_font("Courier", 32) 39 | |> Gutenex.text_render_mode(:stroke) 40 | |> Gutenex.write_text("xyz") 41 | |> Gutenex.end_text 42 | |> Gutenex.move_to(400, 20) 43 | |> Gutenex.draw_image(alpaca_alias, %{ 44 | translate_x: 300, 45 | translate_y: 500, 46 | }) 47 | |> Gutenex.export("./tmp/alpaca.pdf") 48 | |> Gutenex.stop 49 | ``` 50 | 51 | Now open up that file and you should see some text near the bottom and a picture 52 | of what I believe to be an alpaca. Could also be a llama. 53 | 54 | By default, coordinates are in units of 1/72 inch as per the PDF 55 | spec. Origin is in lower left corner of the page. This is roughly 1 56 | point in printing terms. 57 | 58 | ``` 59 | Gutenex.line_width(pid, 0.01) # very fine line 60 | |> Gutenex.line({{0, 0}, {500, 500}}) # up and to the right 61 | ``` 62 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/meta_data_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.MetaDataBuilderTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Context 4 | alias Gutenex.PDF.RenderContext 5 | alias Gutenex.PDF.Builders.MetaDataBuilder 6 | 7 | setup do 8 | render_context = %RenderContext{ 9 | generation_number: 3, 10 | current_index: 100 11 | } 12 | context = %Context{ 13 | meta_data: %{ 14 | creator: "Thomas Paine", 15 | creation_date: {{1776, 7, 4}, {15, 15, 15}}, 16 | producer: "Continental Congress", 17 | author: "America", 18 | title: "Colonial Independence", 19 | subject: "Revolution", 20 | keywords: "free-mp3s how-to-build-a-startup-online stock-tips" 21 | } 22 | } 23 | {:ok, %{render_context: render_context, context: context}} 24 | end 25 | 26 | test "#build", %{render_context: render_context, context: context} do 27 | {new_render_context, _new_context} = MetaDataBuilder.build({render_context, context}) 28 | meta_data_index = render_context.current_index 29 | generation_number = render_context.generation_number 30 | 31 | { 32 | {:obj, ^meta_data_index, ^generation_number}, 33 | {:dict, meta_data} 34 | } = new_render_context.meta_data 35 | 36 | assert new_render_context.meta_data_reference == {:ptr, 100, 3} 37 | assert new_render_context.current_index == meta_data_index + 1 38 | assert Map.get(meta_data, "Title") == {:string, context.meta_data.title} 39 | assert Map.get(meta_data, "Author") == {:string, context.meta_data.author} 40 | assert Map.get(meta_data, "Creator") == {:string, context.meta_data.creator} 41 | assert Map.get(meta_data, "Subject") == {:string, context.meta_data.subject} 42 | assert Map.get(meta_data, "Producer") == {:string, context.meta_data.producer} 43 | assert Map.get(meta_data, "Keywords") == {:string, context.meta_data.keywords} 44 | assert Map.get(meta_data, "CreationDate") == {:date, context.meta_data.creation_date} 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/page_tree_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.PageTreeBuilder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | 5 | def build({%RenderContext{}=render_context, %Context{}=context}) do 6 | updated_render_context = build_x_objects(render_context) 7 | |> build_page_tree(context) 8 | { updated_render_context, context } 9 | end 10 | 11 | defp build_page_tree(%RenderContext{}=render_context, %Context{}=context) do 12 | %RenderContext{ 13 | RenderContext.next_index(render_context) | 14 | page_tree_reference: RenderContext.current_reference(render_context), 15 | page_tree: page_tree(render_context, context) 16 | } 17 | end 18 | 19 | defp page_tree(render_context, context) do 20 | { 21 | RenderContext.current_object(render_context), 22 | {:dict, %{ 23 | "Type" => {:name, "Pages"}, 24 | "Count" => length(render_context.page_references), 25 | "MediaBox" => {:rect, media_box(context.media_box)}, 26 | "Kids" => {:array, render_context.page_references}, 27 | "Resources" => page_resources(render_context)}} 28 | } 29 | end 30 | 31 | defp build_x_objects(%RenderContext{}=render_context) do 32 | %RenderContext{ 33 | RenderContext.next_index(render_context) | 34 | x_object_dictionary: x_object_dictionary(render_context), 35 | x_object_dictionary_reference: RenderContext.current_reference(render_context) 36 | } 37 | end 38 | 39 | # Merge all of the necessary objects for the reference dictionary 40 | defp x_object_dictionary(render_context) do 41 | { 42 | RenderContext.current_object(render_context), 43 | {:dict, render_context.image_aliases} 44 | } 45 | end 46 | 47 | 48 | def media_box({top_left, top_right, bottom_left, bottom_right}) do 49 | [top_left, top_right, bottom_left, bottom_right] 50 | end 51 | 52 | def page_resources(%RenderContext{}=render_context) do 53 | {:dict, %{ 54 | "Font" => {:dict, render_context.font_aliases}, 55 | "XObject" => render_context.x_object_dictionary_reference 56 | }} 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/page_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.PageBuilderTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Context 4 | alias Gutenex.PDF.RenderContext 5 | alias Gutenex.PDF.Builders.PageBuilder 6 | 7 | test "#build adds the page objects" do 8 | page_1 = "1 2 3 4" 9 | page_2 = "alpha beta gamma delta" 10 | page_tree_reference = {:ptr, 39, 0} 11 | 12 | context = %Context{pages: [page_1, page_2], templates: [nil, "Richard"]} 13 | {render_context, ^context} = PageBuilder.build({ 14 | %RenderContext{ 15 | page_tree_reference: page_tree_reference, 16 | page_tree: {{:obj, 1, 0}, {:dict, %{}}} 17 | }, 18 | context 19 | }) 20 | 21 | assert render_context.current_index == 5 22 | assert render_context.page_references == [ 23 | {:ptr, render_context.current_index - 3, render_context.generation_number}, 24 | {:ptr, render_context.current_index - 1, render_context.generation_number}, 25 | ] 26 | 27 | [page_1_contents, page_1_summary, page_2_contents, page_2_summary] = render_context.page_objects 28 | 29 | assert page_1_contents == {{:obj, 1, 0}, {:stream, page_1}} 30 | assert page_1_summary == { 31 | {:obj, 2, 0}, {:dict, %{ 32 | "Type" => {:name, "Page"}, 33 | "Parent" => page_tree_reference, 34 | "Contents" => {:ptr, 1, 0}, 35 | "TemplateInstantiated" => {:name, nil} 36 | }}} 37 | 38 | assert page_2_contents == {{:obj, 3, 0}, {:stream, page_2}} 39 | assert page_2_summary == { 40 | {:obj, 4, 0}, {:dict, %{ 41 | "Type" => {:name, "Page"}, 42 | "Parent" => page_tree_reference, 43 | "Contents" => {:ptr, 3, 0}, 44 | "TemplateInstantiated" => {:name, "Richard"} 45 | }}} 46 | 47 | assert render_context.page_tree == { 48 | {:obj, 1, 0}, 49 | {:dict, %{ 50 | "Count" => 2, 51 | "Kids" => {:array, [{:ptr, 2, 0}, {:ptr, 4, 0}]} 52 | }} 53 | } 54 | 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/gutenex/pdf/text_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.TextTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Text 4 | 5 | test "#begin_text" do 6 | assert Text.begin_text() == "BT\n" 7 | end 8 | 9 | test "#end_text" do 10 | assert Text.end_text() == "ET\n" 11 | end 12 | 13 | test "#break_text" do 14 | assert Text.break_text() == " T*\n" 15 | end 16 | 17 | test "#write_text" do 18 | assert Text.write_text("Bananas") == "(Bananas) Tj\n" 19 | 20 | assert Text.write_text("(Bananas)") == "(\\(Bananas\\)) Tj\n", "it escapes parenthesis" 21 | assert Text.write_text("\\Bananas") == "(\\\\Bananas) Tj\n", "it escapes backslashes" 22 | end 23 | 24 | test "#write_text_br" do 25 | assert Text.write_text_br("Bananas") == "(Bananas) Tj\n T*\n" 26 | end 27 | 28 | test "#text_position" do 29 | assert Text.text_position(200, 30) == "200 30 Td\n" 30 | end 31 | 32 | test "#render_mode" do 33 | assert Text.render_mode(:fill) == "0 Tr\n", 34 | "it can set the fill render mode" 35 | assert Text.render_mode(:stroke) == "1 Tr\n", 36 | "it can set the stroke render mode" 37 | assert Text.render_mode(:fill_stroke) == "2 Tr\n", 38 | "it can set the fill_stroke render mode" 39 | assert Text.render_mode(:invisible) == "3 Tr\n", 40 | "it can set the invisible render mode" 41 | assert Text.render_mode(:fill_clip) == "4 Tr\n", 42 | "it can set the fill_clip render mode" 43 | assert Text.render_mode(:stroke_clip) == "5 Tr\n", 44 | "it can set the stroke_clip render mode" 45 | assert Text.render_mode(:fill_stroke_clip) == "6 Tr\n", 46 | "it can set the fill_stroke_clip render mode" 47 | assert Text.render_mode(:clip) == "7 Tr\n", 48 | "it can set the clip render mode" 49 | end 50 | 51 | test "#character_spacing" do 52 | assert Text.character_spacing(10) == "10 Tc\n" 53 | end 54 | 55 | test "#scale" do 56 | assert Text.scale(90) == "90 Tz\n", "it can set the scale of the text" 57 | assert Text.scale(:web) == "9001 Tz\n", "it can do web scale" 58 | end 59 | 60 | test "#word_spacing" do 61 | assert Text.word_spacing(90) == "90 Tw\n" 62 | end 63 | 64 | test "#line_spacing" do 65 | assert Text.line_spacing(10) == "10 TL\n" 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/gutenex/pdf/builders/page_tree_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.PageTreeBuilderTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Page 4 | alias Gutenex.PDF.Context 5 | alias Gutenex.PDF.RenderContext 6 | alias Gutenex.PDF.Builders.PageTreeBuilder 7 | 8 | test "#media_box converts coordinates to a list" do 9 | assert PageTreeBuilder.media_box({0, 0, 100, 200}) == [0, 0, 100, 200] 10 | end 11 | 12 | test "#page_resources builds a dictionary of fonts and XObjects" do 13 | render_context = %RenderContext{ 14 | font_aliases: %{"Helvetica" => {:ptr, 31, 0}, "Times-Roman" => {:ptr, 28, 0}}, 15 | x_object_dictionary_reference: {:ptr, 2, 0} 16 | } 17 | assert PageTreeBuilder.page_resources(render_context) == { 18 | :dict, %{ 19 | "Font" => {:dict, render_context.font_aliases}, 20 | "XObject" => render_context.x_object_dictionary_reference 21 | } 22 | } 23 | end 24 | 25 | test "#build" do 26 | # Set up the test context and variables 27 | context = %Context{media_box: Page.page_size(:a0)} 28 | render_context = %RenderContext{ 29 | generation_number: 0, 30 | current_index: 99, 31 | page_references: [{:ptr, 4, 0}, {:ptr, 8, 0}, {:ptr, 15, 0}, {:ptr, 16, 0}, 32 | {:ptr, 23, 0}, {:ptr, 42, 0}], 33 | font_aliases: %{ 34 | "Bingo" => {:ptr, 3, 0}, 35 | "Bango" => {:ptr, 6, 0}, 36 | "Bongo" => {:ptr, 9, 0} 37 | } 38 | } 39 | 40 | {updated_render_context, ^context} = PageTreeBuilder.build({render_context, context}) 41 | { 42 | {:obj, 100, 0}, 43 | {:dict, page_tree} 44 | } = updated_render_context.page_tree 45 | 46 | assert updated_render_context.page_tree_reference == {:ptr, 100, 0} 47 | assert Map.get(page_tree, "Type") == {:name, "Pages"} 48 | assert Map.get(page_tree, "Kids") == {:array, 49 | [{:ptr, 4, 0}, {:ptr, 8, 0}, {:ptr, 15, 0}, {:ptr, 16, 0}, {:ptr, 23, 0}, 50 | {:ptr, 42, 0}]} 51 | assert Map.get(page_tree, "Count") == 6 52 | assert Map.get(page_tree, "MediaBox") == {:rect, [0, 0, 2380, 3368]} 53 | assert Map.get(page_tree, "Resources") == {:dict, 54 | %{ 55 | "Font" => {:dict, render_context.font_aliases}, 56 | "XObject" => {:ptr, 99, render_context.generation_number} 57 | } 58 | } 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/template_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.TemplateBuilder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | 5 | @doc """ 6 | If template(s) were used, build references to them and adds the references 7 | to the x_object_reference_dictionary 8 | """ 9 | def build({%RenderContext{}=render_context, %Context{}=context}) do 10 | updated_render_context = build_templates(render_context, context) 11 | {updated_render_context, context} 12 | end 13 | 14 | defp build_templates(render_context, context) do 15 | build_templates(render_context, context, context.templates) 16 | end 17 | 18 | defp build_templates(render_context, _context, []) do 19 | %RenderContext{ 20 | render_context | 21 | template_objects: Enum.reverse(render_context.template_objects) 22 | } 23 | end 24 | 25 | # Skip over nil templates 26 | defp build_templates(render_context, context, [nil | templates]) do 27 | build_templates(render_context, context, templates) 28 | end 29 | 30 | defp build_templates(render_context, context, [template_alias | templates]) do 31 | template = Map.get context.template_aliases, template_alias 32 | template_object = build_template(render_context, context, template) 33 | updated_aliases = Map.put( 34 | render_context.template_aliases, 35 | template_alias, 36 | RenderContext.current_reference(render_context)) 37 | updated_render_context = %RenderContext{ 38 | RenderContext.next_index(render_context) | 39 | template_objects: [template_object | render_context.template_objects], 40 | template_aliases: updated_aliases 41 | } 42 | build_templates(updated_render_context, context, templates) 43 | end 44 | 45 | defp build_template(render_context, context, template_body) do 46 | { 47 | RenderContext.current_object(render_context), 48 | { 49 | :stream, 50 | template_dictionary(context), 51 | template_body 52 | } 53 | } 54 | end 55 | 56 | # Builds the dictionary to describe the template stream, 57 | # setting the bounding box to be the size of the entire page 58 | defp template_dictionary(%Context{}=context) do 59 | { 60 | :dict, 61 | %{ 62 | "Type" => "XObject", 63 | "SubType" => "Form", 64 | "BBox" => {:array, Tuple.to_list(context.media_box)}, 65 | "Resources" => {:dict, %{"ProcSet" => {:name, "PDF"}}} 66 | } 67 | } 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /test/gutenex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GutenexTest do 2 | use ExUnit.Case 3 | 4 | setup do 5 | {:ok, server_pid} = Gutenex.start_link() 6 | {:ok, %{server: server_pid}} 7 | end 8 | 9 | test "can stop server" do 10 | {:ok, server_pid} = Gutenex.start_link() 11 | assert Process.alive? server_pid 12 | 13 | :ok = Gutenex.stop(server_pid) 14 | 15 | assert !Process.alive? server_pid 16 | end 17 | 18 | test "setting the context", %{server: server} do 19 | new_context = %Gutenex.PDF.Context{ meta_data: %{ author: "Kurt Vonnegut", title: "Slaughterhouse-five"} } 20 | Gutenex.context(server, new_context) 21 | 22 | assert new_context == Gutenex.context(server) 23 | end 24 | 25 | test "getting the stream", %{server: server} do 26 | assert <<>> == Gutenex.stream(server) 27 | end 28 | 29 | test "appending to the stream", %{server: server} do 30 | assert <<>> == Gutenex.stream(server) 31 | 32 | Gutenex.append_to_stream(server, "Billy Pilgrim") 33 | Gutenex.append_to_stream(server, ", Kilgore Trout") 34 | Gutenex.append_to_stream(server, ", Roland Weary") 35 | 36 | assert "Billy Pilgrim, Kilgore Trout, Roland Weary" == Gutenex.stream(server) 37 | end 38 | 39 | test "setting the current page", %{server: server} do 40 | assert 1 == Gutenex.context(server).current_page 41 | Gutenex.set_page(server, 2) 42 | assert 2 == Gutenex.context(server).current_page 43 | end 44 | 45 | test "#move_to", %{server: server} do 46 | server = Gutenex.move_to(server, 20, 30) 47 | assert "20 30 m\n" == Gutenex.stream(server) 48 | end 49 | 50 | @tag :integration 51 | test "integration!" do 52 | File.rm("./tmp/alpaca.pdf") 53 | {alpaca_alias, alpaca_rendition} = Gutenex.PDF.Images.load("./test/support/images/alpaca.png") 54 | {:ok, pid} = Gutenex.start_link 55 | Gutenex.add_image(pid, alpaca_alias, alpaca_rendition) 56 | |> Gutenex.begin_text 57 | |> Gutenex.text_leading(48) 58 | |> Gutenex.set_font("Helvetica", 48) 59 | |> Gutenex.text_position(40, 180) 60 | |> Gutenex.text_render_mode(:fill) 61 | |> Gutenex.write_text_br("ABC") 62 | |> Gutenex.set_font("Courier", 32) 63 | |> Gutenex.text_render_mode(:stroke) 64 | |> Gutenex.write_text("xyz") 65 | |> Gutenex.end_text 66 | |> Gutenex.move_to(400, 20) 67 | |> Gutenex.draw_image(alpaca_alias, %{ 68 | translate_x: 300, 69 | translate_y: 500, 70 | }) 71 | |> Gutenex.export("./tmp/alpaca.pdf") 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/gutenex/pdf/serialization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.SerializationTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Serialization 4 | 5 | test "#serialize nil" do 6 | assert Serialization.serialize(nil) == " null " 7 | end 8 | 9 | test "#serialize booleans" do 10 | assert Serialization.serialize(true) == " true " 11 | assert Serialization.serialize(false) == " false " 12 | end 13 | 14 | test "#serialize with a integer" do 15 | assert Serialization.serialize(112392093810293) == "112392093810293" 16 | end 17 | 18 | test "#serialize with a float should format it" do 19 | assert Serialization.serialize(23_456.329) == "23456.33" 20 | end 21 | 22 | test "#serialize a :string" do 23 | assert Serialization.serialize({:string, "Bubbbles!"}) == " (Bubbbles!) " 24 | end 25 | 26 | test "#serialize with an :obj" do 27 | assert Serialization.serialize({{:obj, 1, 0}, true}) == "1 0 obj\n true \nendobj\n" 28 | end 29 | 30 | test "#serialize with a :date" do 31 | assert Serialization.serialize({:date, {{2014, 1, 31},{15, 15, 00}}}) == 32 | " (D:20140131151500) " 33 | assert Serialization.serialize({:date, {1776, 7, 4}}) == 34 | " (D:17760704000000) " 35 | end 36 | 37 | test "#serialize with a :name" do 38 | assert Serialization.serialize({:name, "Harold"}) == " /Harold " 39 | end 40 | 41 | test "#serialize with an :array" do 42 | assert Serialization.serialize({:array, [1, {:string, "Two"}, 3.0, {:date, {1776, 7, 4}}]}) == 43 | " [1, (Two) ,3.00, (D:17760704000000) ] " 44 | end 45 | 46 | test "#serialize with a :dict using a map" do 47 | assert Serialization.serialize({:dict, %{"Key" => "Value", "Numbers" => {:array, [1, 2, 3]}, "Nope" => nil}}) == 48 | "<>" 49 | end 50 | 51 | test "#serialize with a :ptr" do 52 | assert Serialization.serialize({:ptr, 12, 0}) == " 12 0 R " 53 | end 54 | 55 | test "#serialize with a :rect" do 56 | assert Serialization.serialize({:rect, [1, 2, 3, 4]}) == 57 | " [1 2 3 4] " 58 | end 59 | 60 | test "#serialize with a :hexstring" do 61 | assert Serialization.serialize({:hexstring, "Yay Bubbles!"}) == 62 | " <59617920427562626C657321> " 63 | end 64 | 65 | test "#serialize with a :stream with no options" do 66 | assert Serialization.serialize({:stream, "AHHHHHHHHHHHHHHHHHH"}) == 67 | String.rstrip(""" 68 | <> 69 | stream 70 | AHHHHHHHHHHHHHHHHHH 71 | endstream 72 | """) 73 | end 74 | 75 | test "#serialize the :trailer" do 76 | assert Serialization.serialize({:trailer, {:dict, %{ 77 | "Size" => 200, 78 | "Root" => {:ptr, 200, 0}, 79 | "Info" => {:ptr, 5, 1} 80 | }}}) == """ 81 | trailer 82 | <> 83 | """ 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/render_context.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.RenderContext do 2 | alias Gutenex.PDF.RenderContext 3 | defstruct( 4 | generation_number: 0, 5 | current_index: 1, 6 | 7 | # Objects 8 | catalog: nil, 9 | meta_data: nil, 10 | page_tree: nil, 11 | x_object_dictionary: nil, 12 | image_objects: [], 13 | font_objects: [], 14 | page_objects: [], 15 | template_objects: [], 16 | 17 | # References 18 | catalog_reference: nil, 19 | meta_data_reference: nil, 20 | page_tree_reference: nil, 21 | x_object_dictionary_reference: nil, 22 | image_summary_reference: nil, 23 | page_references: [], 24 | 25 | # Aliases 26 | font_aliases: %{}, 27 | image_aliases: %{}, 28 | template_aliases: %{} 29 | ) 30 | 31 | @doc """ 32 | Returns RenderContext where the render context's current_index 33 | has been incremented by one 34 | """ 35 | def next_index(%RenderContext{}=render_context) do 36 | %RenderContext{render_context | current_index: render_context.current_index + 1} 37 | end 38 | 39 | @doc """ 40 | Returns a reference to the current index and generation number of the provided 41 | render context 42 | """ 43 | def current_reference(%RenderContext{}=render_context) do 44 | {:ptr, render_context.current_index, render_context.generation_number} 45 | end 46 | 47 | @doc """ 48 | Returns an :obj with the current index and generation number of the provided 49 | render context 50 | """ 51 | def current_object(%RenderContext{}=render_context) do 52 | {:obj, render_context.current_index, render_context.generation_number} 53 | end 54 | 55 | @doc """ 56 | Returns a list of all font references for the given render context 57 | """ 58 | def font_references(%RenderContext{}=render_context) do 59 | Map.values render_context.font_aliases 60 | end 61 | 62 | @doc """ 63 | Returns a list of all image references for the given render context 64 | """ 65 | def image_references(%RenderContext{}=render_context) do 66 | Map.values render_context.image_aliases 67 | end 68 | 69 | @doc """ 70 | Returns a list of all objects for rendering 71 | """ 72 | def objects(%RenderContext{}=render_context) do 73 | List.flatten([ 74 | render_context.x_object_dictionary, 75 | render_context.page_tree, 76 | render_context.image_objects, 77 | render_context.font_objects, 78 | render_context.template_objects, 79 | render_context.page_objects, 80 | render_context.catalog, 81 | render_context.meta_data]) 82 | |> Enum.sort_by(&object_sort/1) 83 | end 84 | 85 | defp object_sort({{:obj, index, _}, _}) do 86 | index 87 | end 88 | 89 | @doc """ 90 | """ 91 | def trailer(%RenderContext{}=render_context) do 92 | {:trailer, {:dict, %{ 93 | "Size" => length(objects(render_context)) + 1, 94 | "Root" => render_context.catalog_reference, 95 | "Info" => render_context.meta_data_reference 96 | }}} 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/text.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Text do 2 | import Gutenex.PDF.Utils 3 | 4 | @begin_text_marker "BT\n" 5 | @end_text_marker "ET\n" 6 | @break_text_marker " T*\n" 7 | 8 | def begin_text do 9 | @begin_text_marker 10 | end 11 | 12 | def end_text do 13 | @end_text_marker 14 | end 15 | 16 | def break_text do 17 | @break_text_marker 18 | end 19 | 20 | def write_text(text_to_write) do 21 | "(#{escape(text_to_write)}) Tj\n" 22 | end 23 | 24 | def write_text_br(text_to_write) do 25 | write_text(text_to_write) <> break_text 26 | end 27 | 28 | def render_mode(mode) do 29 | "#{text_fill(mode)} Tr\n" 30 | end 31 | 32 | 33 | # :fill Fill text 34 | # :stroke Stroke text 35 | # :fill_stroke Fill, then stroke text 36 | # :invisible Neither fill nor stroke text (invisible) 37 | # :fill_clip Fill text and add to path for clipping (see above) 38 | # :stroke_clip Stroke text and add to path for clipping 39 | # :fill_stroke_clip Fill, then stroke text and add to path for clipping 40 | # :clip Add text to path for clipping 41 | def text_fill(:fill), do: 0 42 | def text_fill(:stroke), do: 1 43 | def text_fill(:fill_stroke), do: 2 44 | def text_fill(:invisible), do: 3 45 | def text_fill(:fill_clip), do: 4 46 | def text_fill(:stroke_clip), do: 5 47 | def text_fill(:fill_stroke_clip), do: 6 48 | def text_fill(:clip), do: 7 49 | 50 | 51 | # Moves to the next line and positions the cursor offset by 52 | # (x_coordinate, y_coordinate) 53 | def text_position(x_coordinate, y_coordinate) do 54 | "#{x_coordinate} #{y_coordinate} Td\n" 55 | end 56 | 57 | # Set the character spacing, Tc, to `spacing`, a number 58 | # expressed in unscaled text space units. Character spacing is used 59 | # by the Tj, TJ, and ' operators. Initial value: 0. 60 | def character_spacing(spacing) do 61 | "#{spacing} Tc\n" 62 | end 63 | 64 | # Set the horizontal scaling, Th, to (`scale_percent` ÷ 100). `scale_percent` 65 | # should be a number specifying the percentage of the normal width. 66 | # Initial value: 100 (normal width). 67 | def scale(scale_percent) do 68 | "#{normalized_scale_percentage(scale_percent)} Tz\n" 69 | end 70 | 71 | # It is very important to achieve webscale 72 | defp normalized_scale_percentage(:web), do: 9001 73 | defp normalized_scale_percentage(anything_else), do: anything_else 74 | 75 | # Set the word spacing, Tw, to `spacing`, a number 76 | # expressed in unscaled text space units. Word spacing is used by 77 | # the Tj, TJ, and ' operators. Initial value: 0. 78 | def word_spacing(spacing) do 79 | "#{spacing} Tw\n" 80 | end 81 | 82 | # Sets the distance between baselines of two lines, referred to as "text 83 | # leading." Text leading is only used by the T*, ', and 84 | # " operators. Initial value: 0. 85 | def line_spacing(spacing) do 86 | "#{spacing} TL\n" 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/page_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.PageBuilder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | 5 | @doc """ 6 | Pages are built into two objects 7 | The first contains the stream of the page contents 8 | The second is a dictionary describing the page, a reference to the 9 | page tree, and a reference to the page contents 10 | """ 11 | def build({%RenderContext{}=render_context, %Context{}=context}) do 12 | updated_render_context = build_pages(render_context, context.pages, context.templates) 13 | |> add_page_references_to_page_tree 14 | {updated_render_context, context} 15 | end 16 | 17 | defp build_pages(render_context, []=_pages_left_to_build, _templates) do 18 | %RenderContext{ 19 | render_context | 20 | page_references: Enum.reverse(render_context.page_references), 21 | page_objects: Enum.reverse(render_context.page_objects) 22 | } 23 | end 24 | 25 | defp build_pages(render_context, [page|pages_left_to_build], [template|templates]) do 26 | render_context = add_page(render_context, page) 27 | |> add_page_summary(template) 28 | # We are adding two objects so next index should be two greater than start 29 | build_pages(render_context, pages_left_to_build, templates) 30 | end 31 | 32 | defp add_page(%RenderContext{page_objects: page_objects}=render_context, page) do 33 | %RenderContext{ 34 | RenderContext.next_index(render_context) | 35 | page_objects: [ page_object(render_context, page) | page_objects ] 36 | } 37 | end 38 | 39 | defp page_object(render_context, page) do 40 | { 41 | RenderContext.current_object(render_context), 42 | {:stream, page} 43 | } 44 | end 45 | 46 | defp add_page_summary(%RenderContext{}=render_context, template) do 47 | %RenderContext{ 48 | RenderContext.next_index(render_context) | 49 | page_objects: [page_summary(render_context, template) | render_context.page_objects], 50 | page_references: [page_reference(render_context) | render_context.page_references] 51 | } 52 | end 53 | 54 | # without a template 55 | defp page_summary(render_context, template) do 56 | { 57 | RenderContext.current_object(render_context), 58 | {:dict, %{ 59 | "Type" => {:name, "Page"}, 60 | "Parent" => render_context.page_tree_reference, 61 | "Contents" => {:ptr, render_context.current_index - 1, render_context.generation_number}, 62 | "TemplateInstantiated" => {:name, template} 63 | }} 64 | } 65 | end 66 | 67 | defp page_reference(render_context) do 68 | RenderContext.current_reference(render_context) 69 | end 70 | 71 | defp add_page_references_to_page_tree(render_context) do 72 | { 73 | {:obj, _, _}=page_tree_obj, 74 | {:dict, page_tree_dict} 75 | } = render_context.page_tree 76 | updated_page_tree = Map.put(page_tree_dict, "Kids", {:array, render_context.page_references}) 77 | |> Map.put("Count", length(render_context.page_references)) 78 | %RenderContext{ 79 | render_context | 80 | page_tree: { 81 | page_tree_obj, 82 | {:dict, updated_page_tree} 83 | } 84 | } 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/gutenex/pdf/render_context_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.RenderContextTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.RenderContext 4 | 5 | test "#next_index" do 6 | render_context = %RenderContext{current_index: 11} 7 | new_render_context = RenderContext.next_index(render_context) 8 | assert new_render_context.current_index == render_context.current_index + 1 9 | end 10 | 11 | test "#image_references returns the references from the image aliases" do 12 | render_context = %RenderContext{ 13 | image_aliases: %{ 14 | "Alpaca" => {:ptr, 1, 0}, 15 | "Byzantium" => {:ptr, 2, 0} 16 | } 17 | } 18 | assert RenderContext.image_references(render_context) == 19 | Map.values(render_context.image_aliases) 20 | end 21 | 22 | test "#font_references returns the references from the font aliases" do 23 | render_context = %RenderContext{ 24 | font_aliases: %{ 25 | "Bingo" => {:ptr, 3, 0}, 26 | "Bango" => {:ptr, 6, 0}, 27 | "Bongo" => {:ptr, 9, 0} 28 | } 29 | } 30 | assert RenderContext.font_references(render_context) == 31 | Map.values(render_context.font_aliases) 32 | end 33 | 34 | test "#current_object returns an :obj with the current index and generation number" do 35 | render_context = %RenderContext{ 36 | current_index: 17, 37 | generation_number: 88 38 | } 39 | assert RenderContext.current_object(render_context) == 40 | {:obj, render_context.current_index, render_context.generation_number} 41 | end 42 | 43 | test "#current_reference returns a :ptr with the current index and generation number" do 44 | render_context = %RenderContext{ 45 | current_index: 17, 46 | generation_number: 88 47 | } 48 | assert RenderContext.current_reference(render_context) == 49 | {:ptr, render_context.current_index, render_context.generation_number} 50 | end 51 | 52 | test "#objects returns all of the objects, sorted by object index" do 53 | render_context = %RenderContext{ 54 | catalog: {{:obj, 1, 0}, {:dict, %{}}}, 55 | meta_data: {{:obj, 13, 0}, {:dict, %{}}}, 56 | page_tree: {{:obj, 3, 0}, {:dict, %{}}}, 57 | x_object_dictionary: {{:obj, 4, 0}, {:dict, %{}}}, 58 | image_objects: [ 59 | {{:obj, 7, 0}, {:stream, %{}, ""}}, 60 | {{:obj, 5, 0}, {:stream, %{}, ""}}, 61 | {{:obj, 6, 0},{:stream, %{}, ""}}], 62 | font_objects: [ 63 | {{:obj, 8, 0}, {:ptr, 1000, 0}}, 64 | {{:obj, 10, 0}, {:ptr, 1001, 0}}], 65 | page_objects: [ 66 | {{:obj, 9, 0}, {:ptr, 31, 0}}, 67 | {{:obj, 12, 0}, {:ptr, 29, 0}}, 68 | {{:obj, 11, 0}, {:ptr, 101}}], 69 | template_objects: [{{:obj, 2, 0}, {:ptr, 177, 0}}], 70 | } 71 | 72 | objects = RenderContext.objects(render_context) 73 | |> Enum.map(fn object -> 74 | Tuple.to_list(object) |> List.first 75 | end) 76 | 77 | assert objects == [ 78 | {:obj, 1, 0}, 79 | {:obj, 2, 0}, 80 | {:obj, 3, 0}, 81 | {:obj, 4, 0}, 82 | {:obj, 5, 0}, 83 | {:obj, 6, 0}, 84 | {:obj, 7, 0}, 85 | {:obj, 8, 0}, 86 | {:obj, 9, 0}, 87 | {:obj, 10, 0}, 88 | {:obj, 11, 0}, 89 | {:obj, 12, 0}, 90 | {:obj, 13, 0} 91 | ] 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/builders/image_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Builders.ImageBuilder do 2 | alias Gutenex.PDF.Context 3 | alias Gutenex.PDF.RenderContext 4 | alias Gutenex.PDF.Images 5 | 6 | def build({%RenderContext{}=render_context, %Context{}=context}) do 7 | render_context = add_images(render_context, Map.to_list(context.images)) 8 | {render_context, context} 9 | end 10 | 11 | def add_images(%RenderContext{}=render_context, []) do 12 | %RenderContext{ 13 | render_context | 14 | image_objects: Enum.reverse(render_context.image_objects) 15 | } 16 | end 17 | 18 | def add_images(%RenderContext{}=render_context, [{image_alias, current_image} | images_tail]) do 19 | add_image(render_context, current_image, image_alias) 20 | |> add_images(images_tail) 21 | end 22 | 23 | defp add_image(render_context, image, image_alias) do 24 | add_image_extra_object(render_context, image) 25 | |> add_image_object(image) 26 | |> add_image_alias(image_alias) 27 | |> RenderContext.next_index 28 | end 29 | 30 | @doc """ 31 | Calculate the attributes, any additional objects, and add the image to the 32 | list of images 33 | """ 34 | defp add_image_object(%RenderContext{}=render_context, %Imagineer.Image.PNG{}=image) do 35 | image_object = { 36 | RenderContext.current_object(render_context), 37 | {:stream, 38 | image_attributes(image, extra_attributes(image)), 39 | Imagineer.Image.PNG.to_binary(image) 40 | } 41 | } 42 | %RenderContext{ 43 | render_context | 44 | image_objects: [image_object | render_context.image_objects] 45 | } 46 | end 47 | 48 | @doc """ 49 | Adds the alias to the RenderContext#image_aliases map, under the assumption 50 | that the current index is that of the image object 51 | """ 52 | defp add_image_alias(render_context, image_alias) do 53 | image_reference = RenderContext.current_reference(render_context) 54 | %RenderContext{ 55 | render_context | 56 | image_aliases: Map.put(render_context.image_aliases, image_alias, image_reference) 57 | } 58 | end 59 | 60 | @doc """ 61 | Extra attributes specific to the image format, color type, or other attributes 62 | """ 63 | def extra_attributes(%Imagineer.Image.PNG{color_type: 2 }=image) do 64 | %{ 65 | "Filter" => {:name, "FlateDecode"}, 66 | "ColorSpace" => {:name, Images.png_color(image.color_type)}, 67 | "DecodeParms" => decode_params(image), 68 | "BitsPerComponent" => image.bit_depth 69 | } 70 | end 71 | 72 | @doc """ 73 | PNGs with color type 2 have no extra object 74 | returns the render_context 75 | """ 76 | defp add_image_extra_object(render_context, %Imagineer.Image.PNG{ color_type: 2 }) do 77 | render_context 78 | end 79 | 80 | defp image_attributes(image, extra_attributes) do 81 | {:dict, 82 | Map.merge(%{ 83 | "Type" => {:name, "XObject"}, 84 | "Width" => image.width, 85 | "Height" => image.height, 86 | "Subtype" => {:name, "Image"} 87 | }, extra_attributes) 88 | } 89 | end 90 | 91 | defp decode_params(image) do 92 | { 93 | :dict, 94 | %{ 95 | "Colors" => Images.png_bits(image.color_type), 96 | "Columns" => image.width, 97 | "Predictor" => 15, 98 | "BitsPerComponent" => image.bit_depth 99 | } 100 | } 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/font.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Font do 2 | # Helpful PDF on fonts in PDFs: http://www.ntg.nl/eurotex/KacvinskyPDF.pdf 3 | 4 | @default_font_size 12 5 | @default_font "Helvetica" 6 | @standard_fonts %{ 7 | "Times-Roman" => %{ 8 | "Encoding" => {:name, "MacRomanEncoding"}, 9 | "Type" => {:name, "Font"}, 10 | "Subtype" => {:name, "Type1"}, 11 | "BaseFont" => {:name, "Times-Roman" }}, 12 | "Times-Italic" => %{ 13 | "Encoding" => {:name, "MacRomanEncoding"}, 14 | "Type" => {:name, "Font"}, 15 | "Subtype" => {:name, "Type1"}, 16 | "BaseFont" => {:name, "Times-Italic" }}, 17 | "Times-Bold" => %{ 18 | "Encoding" => {:name, "MacRomanEncoding"}, 19 | "Type" => {:name, "Font"}, 20 | "Subtype" => {:name, "Type1"}, 21 | "BaseFont" => {:name, "Times-Bold" }}, 22 | "Times-BoldItalic" => %{ 23 | "Encoding" => {:name, "MacRomanEncoding"}, 24 | "Type" => {:name, "Font"}, 25 | "Subtype" => {:name, "Type1"}, 26 | "BaseFont" => {:name, "Times-BoldItalic" }}, 27 | "Helvetica" => %{ 28 | "Encoding" => {:name, "MacRomanEncoding"}, 29 | "Type" => {:name, "Font"}, 30 | "Subtype" => {:name, "Type1"}, 31 | "BaseFont" => {:name, "Helvetica" }}, 32 | "Helvetica-Oblique" => %{ 33 | "Encoding" => {:name, "MacRomanEncoding"}, 34 | "Type" => {:name, "Font"}, 35 | "Subtype" => {:name, "Type1"}, 36 | "BaseFont" => {:name, "Helvetica-Oblique" }}, 37 | "Helvetica-Bold" => %{ 38 | "Encoding" => {:name, "MacRomanEncoding"}, 39 | "Type" => {:name, "Font"}, 40 | "Subtype" => {:name, "Type1"}, 41 | "BaseFont" => {:name, "Helvetica-Bold" }}, 42 | "Helvetica-BoldOblique" => %{ 43 | "Encoding" => {:name, "MacRomanEncoding"}, 44 | "Type" => {:name, "Font"}, 45 | "Subtype" => {:name, "Type1"}, 46 | "BaseFont" => {:name, "Helvetica-BoldOblique" }}, 47 | "Courier" => %{ 48 | "Encoding" => {:name, "MacRomanEncoding"}, 49 | "Type" => {:name, "Font"}, 50 | "Subtype" => {:name, "Type1"}, 51 | "BaseFont" => {:name, "Courier" }}, 52 | "Courier-Oblique" => %{ 53 | "Encoding" => {:name, "MacRomanEncoding"}, 54 | "Type" => {:name, "Font"}, 55 | "Subtype" => {:name, "Type1"}, 56 | "BaseFont" => {:name, "Courier-Oblique" }}, 57 | "Courier-Bold" => %{ 58 | "Encoding" => {:name, "MacRomanEncoding"}, 59 | "Type" => {:name, "Font"}, 60 | "Subtype" => {:name, "Type1"}, 61 | "BaseFont" => {:name, "Courier-Bold" }}, 62 | "Courier-BoldOblique" => %{ 63 | "Encoding" => {:name, "MacRomanEncoding"}, 64 | "Type" => {:name, "Font"}, 65 | "Subtype" => {:name, "Type1"}, 66 | "BaseFont" => {:name, "Courier-BoldOblique" }}, 67 | "Symbol" => %{ 68 | "Encoding" => {:name, "MacRomanEncoding"}, 69 | "Type" => {:name, "Font"}, 70 | "Subtype" => {:name, "Type1"}, 71 | "BaseFont" => {:name, "Symbol" }}, 72 | "ZapfDingbats" => %{ 73 | "Encoding" => {:name, "MacRomanEncoding"}, 74 | "Type" => {:name, "Font"}, 75 | "Subtype" => {:name, "Type1"}, 76 | "BaseFont" => {:name, "ZapfDingbats" }} 77 | } 78 | 79 | def standard_fonts do 80 | @standard_fonts 81 | end 82 | 83 | def default_font_size do 84 | @default_font_size 85 | end 86 | 87 | 88 | def set_font(%{}=fonts, font_name, font_size \\ @default_font_size) do 89 | font_name = if Map.has_key?(fonts, font_name) do 90 | font_name 91 | else 92 | @default_font 93 | end 94 | "/#{font_name} #{font_size} Tf\n" 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/page/page_sizes.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Page.PageSizes do 2 | @moduledoc """ 3 | Functions to set common page sizes by atom. 4 | """ 5 | @a0 {2380, 3368} 6 | @a1 {1684, 2380} 7 | @a2 {1190, 1684} 8 | @a3 {842, 1190} 9 | @a4 {595, 842} 10 | @a5 {421, 595} 11 | @a6 {297, 421} 12 | @a7 {210, 297} 13 | @a8 {148, 210} 14 | @a9 {105, 148} 15 | @b0 {2836, 4008} 16 | @b1 {2004, 2836} 17 | @b2 {1418, 2004} 18 | @b3 {1002, 1418} 19 | @b4 {709, 1002} 20 | @b5 {501, 709} 21 | @b6 {355, 501} 22 | @b7 {250, 355} 23 | @b8 {178, 250} 24 | @b9 {125, 178} 25 | @b10 {89, 125} 26 | @c5e {462, 649} 27 | @comm10e {298, 683} 28 | @dle {312, 624} 29 | @executive {542, 720} 30 | @folio {595, 935} 31 | @ledger {1224, 792} 32 | @legal {612, 1008} 33 | @letter {612, 792} 34 | @tabloid {792, 1224} 35 | 36 | def __using__(_) do 37 | quote do 38 | def page_size(width, height) when is_integer(width) and is_integer(height) do 39 | {0, 0, width, height} 40 | end 41 | 42 | def page_size({width, height}) when is_integer(width) and is_integer(height) do 43 | page_size(width, height) 44 | end 45 | 46 | def page_size(:a0) do 47 | page_size(@a0) 48 | end 49 | 50 | def page_size(:a1) do 51 | page_size(@a1) 52 | end 53 | 54 | def page_size(:a2) do 55 | page_size(@a2) 56 | end 57 | 58 | def page_size(:a3) do 59 | page_size(@a3) 60 | end 61 | 62 | def page_size(:a4) do 63 | page_size(@a4) 64 | end 65 | 66 | def page_size(:a5) do 67 | page_size(@a5) 68 | end 69 | 70 | def page_size(:a6) do 71 | page_size(@a6) 72 | end 73 | 74 | def page_size(:a7) do 75 | page_size(@a7) 76 | end 77 | 78 | def page_size(:a8) do 79 | page_size(@a8) 80 | end 81 | 82 | def page_size(:a9) do 83 | page_size(@a9) 84 | end 85 | 86 | def page_size(:b0) do 87 | page_size(@b0) 88 | end 89 | 90 | def page_size(:b1) do 91 | page_size(@b1) 92 | end 93 | 94 | def page_size(:b2) do 95 | page_size(@b2) 96 | end 97 | 98 | def page_size(:b3) do 99 | page_size(@b3) 100 | end 101 | 102 | def page_size(:b4) do 103 | page_size(@b4) 104 | end 105 | 106 | def page_size(:b5) do 107 | page_size(@b5) 108 | end 109 | 110 | def page_size(:b6) do 111 | page_size(@b6) 112 | end 113 | 114 | def page_size(:b7) do 115 | page_size(@b7) 116 | end 117 | 118 | def page_size(:b8) do 119 | page_size(@b8) 120 | end 121 | 122 | def page_size(:b9) do 123 | page_size(@b9) 124 | end 125 | 126 | def page_size(:b10) do 127 | page_size(@b10) 128 | end 129 | 130 | def page_size(:c5e) do 131 | page_size(@c5e) 132 | end 133 | 134 | def page_size(:comm10e) do 135 | page_size(@comm10e) 136 | end 137 | 138 | def page_size(:dle) do 139 | page_size(@dle) 140 | end 141 | 142 | def page_size(:executive) do 143 | page_size(@executive) 144 | end 145 | 146 | def page_size(:folio) do 147 | page_size(@folio) 148 | end 149 | 150 | def page_size(:ledger) do 151 | page_size(@ledger) 152 | end 153 | 154 | def page_size(:legal) do 155 | page_size(@legal) 156 | end 157 | 158 | def page_size(:letter) do 159 | page_size(@letter) 160 | end 161 | 162 | def page_size(:tabloid) do 163 | page_size(@tabloid) 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/serialization.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Serialization do 2 | @moduledoc """ 3 | Need to serialize elixir into PDF format? You came to the right place! 4 | 5 | ``` 6 | defmodule Walrus do 7 | import Gutenex.PDF.Serialization 8 | 9 | def to_pdf(attributes) do 10 | serialize({:dict, Dict.to_list(attributes)}) 11 | end 12 | end 13 | ``` 14 | """ 15 | def serialize(nil) do 16 | " null " 17 | end 18 | 19 | def serialize(true) do 20 | " true " 21 | end 22 | 23 | def serialize(false) do 24 | " false " 25 | end 26 | 27 | def serialize(float) when is_float(float) do 28 | Float.to_string(float, [decimals: 2]) 29 | end 30 | 31 | def serialize(integer) when is_integer(integer) do 32 | Integer.to_string(integer) 33 | end 34 | 35 | def serialize({:string, str}) do 36 | " (#{str}) " 37 | end 38 | 39 | def serialize({:hexstring, str}) do 40 | " <#{Base.encode16 str}> " 41 | end 42 | 43 | def serialize({:name, name}) do 44 | " /#{name} " 45 | end 46 | 47 | def serialize({:ptr, object_number, generation_number}) do 48 | " #{object_number} #{generation_number} R " 49 | end 50 | 51 | def serialize({:date, {{year, month, day}, {hours, minutes, seconds}}}) do 52 | formatted_date_string = 53 | Enum.map([month, day, hours, minutes, seconds], &format_date_part(&1)) |> 54 | Enum.join() 55 | 56 | " (D:#{year}" <> formatted_date_string <> ") " 57 | end 58 | 59 | def serialize({:date, {_year, _month, _day} = date}) do 60 | serialize({:date, {date, {0, 0, 0}}}) 61 | end 62 | 63 | def serialize({{:obj, object_number, generation_number}, object}) do 64 | """ 65 | #{serialize object_number} #{serialize generation_number} obj 66 | #{serialize object} 67 | endobj 68 | """ 69 | end 70 | 71 | def serialize({:array, elements}) when is_list(elements) do 72 | inner = Enum.map(elements, &serialize/1) 73 | |> Enum.join(",") 74 | " [" <> inner <> "] " 75 | end 76 | 77 | def serialize({:rect, elements}) do 78 | inner = Enum.map(elements, &serialize/1) 79 | |> Enum.join(" ") 80 | " [" <> inner <> "] " 81 | end 82 | 83 | def serialize({:dict, map}) when is_map(map) do 84 | "<<#{serialize_dictionary_pairs(map)}>>" 85 | end 86 | 87 | def serialize({:stream, {:dict, options}, payload}) when is_binary(payload) do 88 | {options, payload} = prepare_stream(options, payload) 89 | serialize({:dict, options}) <> "\n" <> 90 | Enum.join(["stream", payload, "endstream"], "\n") 91 | end 92 | 93 | def serialize({:stream, payload}) when is_binary(payload) do 94 | serialize({:stream, {:dict, %{}}, payload}) 95 | end 96 | 97 | def serialize({:trailer, {:dict, _dict}=trailer}) do 98 | """ 99 | trailer 100 | #{serialize(trailer)} 101 | """ 102 | end 103 | 104 | 105 | def serialize(untyped) when is_binary(untyped) do 106 | serialize({:string, untyped}) 107 | end 108 | 109 | # Takes in the options and payload: 110 | # - Encodes the payload if it knows how (it currently knows nothing) 111 | # - Adds the "Length" key to the options 112 | # Returns the {modified_options, encoded_payload} 113 | # TODO: Implement filters defined on page PDF 42 of 114 | # http://partners.adobe.com/public/developer/en/pdf/PDFReference.pdf 115 | defp prepare_stream(options, payload) do 116 | options = Map.put(options, "Length", String.length(payload)) 117 | {options, payload} 118 | end 119 | 120 | def serialize_dictionary_pairs(map) do 121 | Enum.reject(map, fn ({_key, value}) -> value == nil end) 122 | |> Enum.map(&serialize_dictionary_pair/1) 123 | |> Enum.join() 124 | end 125 | 126 | def serialize_dictionary_pair({key, value}) do 127 | serialized_key = String.strip(serialize({:name, key})) 128 | serialized_value = String.strip(serialize(value)) 129 | serialized_key <> " " <> serialized_value 130 | end 131 | 132 | defp format_date_part(integer) do 133 | if integer >= 10 do 134 | to_string integer 135 | else 136 | "0#{to_string integer}" 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/gutenex/pdf/page.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.Page do 2 | use Gutenex.PDF.Page.PageSizes 3 | 4 | def to_pdf( parent_reference, contents_reference, generation_number, options \\ %{}) do 5 | { 6 | :dict, 7 | [ 8 | {"Type", {:name, "Page"}}, 9 | {"Parent", {:ptr, parent_reference, generation_number}}, 10 | {"Contents", {:ptr, contents_reference, generation_number}} | 11 | Enum.map(options, &page_option(&1)) 12 | ] 13 | } 14 | end 15 | 16 | defp page_option({key, value}) do 17 | atom_to_page_key(key) |> 18 | page_option(value) 19 | end 20 | 21 | 22 | defp page_option("LastModified", value) do 23 | {"LastModified", {:date, value}} 24 | end 25 | 26 | defp page_option(key, value) do 27 | {key, value} 28 | end 29 | 30 | defp atom_to_page_key(:last_modified), do: "LastModified" 31 | defp atom_to_page_key(:resources), do: "Resources" 32 | defp atom_to_page_key(:annotations), do: "Annots" 33 | defp atom_to_page_key(anything), do: anything 34 | 35 | @a0 {2380, 3368} 36 | @a1 {1684, 2380} 37 | @a2 {1190, 1684} 38 | @a3 {842, 1190} 39 | @a4 {595, 842} 40 | @a5 {421, 595} 41 | @a6 {297, 421} 42 | @a7 {210, 297} 43 | @a8 {148, 210} 44 | @a9 {105, 148} 45 | @b0 {2836, 4008} 46 | @b1 {2004, 2836} 47 | @b2 {1418, 2004} 48 | @b3 {1002, 1418} 49 | @b4 {709, 1002} 50 | @b5 {501, 709} 51 | @b6 {355, 501} 52 | @b7 {250, 355} 53 | @b8 {178, 250} 54 | @b9 {125, 178} 55 | @b10 {89, 125} 56 | @c5e {462, 649} 57 | @comm10e {298, 683} 58 | @dle {312, 624} 59 | @executive {542, 720} 60 | @folio {595, 935} 61 | @ledger {1224, 792} 62 | @legal {612, 1008} 63 | @letter {612, 792} 64 | @tabloid {792, 1224} 65 | 66 | def page_size(width, height) when is_integer(width) and is_integer(height) do 67 | {0, 0, width, height} 68 | end 69 | 70 | def page_size({width, height}) when is_integer(width) and is_integer(height) do 71 | page_size(width, height) 72 | end 73 | 74 | def page_size(:a0) do 75 | page_size(@a0) 76 | end 77 | 78 | def page_size(:a1) do 79 | page_size(@a1) 80 | end 81 | 82 | def page_size(:a2) do 83 | page_size(@a2) 84 | end 85 | 86 | def page_size(:a3) do 87 | page_size(@a3) 88 | end 89 | 90 | def page_size(:a4) do 91 | page_size(@a4) 92 | end 93 | 94 | def page_size(:a5) do 95 | page_size(@a5) 96 | end 97 | 98 | def page_size(:a6) do 99 | page_size(@a6) 100 | end 101 | 102 | def page_size(:a7) do 103 | page_size(@a7) 104 | end 105 | 106 | def page_size(:a8) do 107 | page_size(@a8) 108 | end 109 | 110 | def page_size(:a9) do 111 | page_size(@a9) 112 | end 113 | 114 | def page_size(:b0) do 115 | page_size(@b0) 116 | end 117 | 118 | def page_size(:b1) do 119 | page_size(@b1) 120 | end 121 | 122 | def page_size(:b2) do 123 | page_size(@b2) 124 | end 125 | 126 | def page_size(:b3) do 127 | page_size(@b3) 128 | end 129 | 130 | def page_size(:b4) do 131 | page_size(@b4) 132 | end 133 | 134 | def page_size(:b5) do 135 | page_size(@b5) 136 | end 137 | 138 | def page_size(:b6) do 139 | page_size(@b6) 140 | end 141 | 142 | def page_size(:b7) do 143 | page_size(@b7) 144 | end 145 | 146 | def page_size(:b8) do 147 | page_size(@b8) 148 | end 149 | 150 | def page_size(:b9) do 151 | page_size(@b9) 152 | end 153 | 154 | def page_size(:b10) do 155 | page_size(@b10) 156 | end 157 | 158 | def page_size(:c5e) do 159 | page_size(@c5e) 160 | end 161 | 162 | def page_size(:comm10e) do 163 | page_size(@comm10e) 164 | end 165 | 166 | def page_size(:dle) do 167 | page_size(@dle) 168 | end 169 | 170 | def page_size(:executive) do 171 | page_size(@executive) 172 | end 173 | 174 | def page_size(:folio) do 175 | page_size(@folio) 176 | end 177 | 178 | def page_size(:ledger) do 179 | page_size(@ledger) 180 | end 181 | 182 | def page_size(:legal) do 183 | page_size(@legal) 184 | end 185 | 186 | def page_size(:letter) do 187 | page_size(@letter) 188 | end 189 | 190 | def page_size(:tabloid) do 191 | page_size(@tabloid) 192 | end 193 | 194 | 195 | end 196 | -------------------------------------------------------------------------------- /test/gutenex/pdf/page_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Gutenex.PDF.PageTest do 2 | use ExUnit.Case, async: true 3 | alias Gutenex.PDF.Page 4 | 5 | 6 | test "#to_pdf with no options should build the object" do 7 | expected = {:dict, [ 8 | {"Type", {:name, "Page"}}, 9 | {"Parent", {:ptr, 5, 3}}, 10 | {"Contents", {:ptr, 1_000, 3}}]} 11 | assert Page.to_pdf(5, 1_000, 3) == expected 12 | end 13 | 14 | test "#to_pdf with options should translate them to strings" do 15 | options = %{last_modified: {{2014, 1, 31},{15, 15, 00}}} 16 | generation_number = 7 17 | expected = {:dict, [ 18 | {"Type", {:name, "Page"}}, 19 | {"Parent", {:ptr, 5, generation_number}}, 20 | {"Contents", {:ptr, 1_000, generation_number}}, 21 | {"LastModified", {:date, options[:last_modified]}} 22 | ]} 23 | assert Page.to_pdf(5, 1_000, generation_number, options) == expected 24 | end 25 | 26 | test "#page_size with arbitrary width and height" do 27 | assert Page.page_size(29_999, 38_792) == {0, 0, 29_999, 38_792} 28 | end 29 | 30 | test "#page_size :a0" do 31 | assert Page.page_size(:a0) == {0, 0, 2380, 3368} 32 | end 33 | 34 | test "#page_size :a1" do 35 | assert Page.page_size(:a1) == {0, 0, 1684, 2380} 36 | end 37 | 38 | test "#page_size :a2" do 39 | assert Page.page_size(:a2) == {0, 0, 1190, 1684} 40 | end 41 | 42 | test "#page_size :a3" do 43 | assert Page.page_size(:a3) == {0, 0, 842, 1190} 44 | end 45 | 46 | test "#page_size :a4" do 47 | assert Page.page_size(:a4) == {0, 0, 595, 842} 48 | end 49 | 50 | test "#page_size :a5" do 51 | assert Page.page_size(:a5) == {0, 0, 421, 595} 52 | end 53 | 54 | test "#page_size :a6" do 55 | assert Page.page_size(:a6) == {0, 0, 297, 421} 56 | end 57 | 58 | test "#page_size :a7" do 59 | assert Page.page_size(:a7) == {0, 0, 210, 297} 60 | end 61 | 62 | test "#page_size :a8" do 63 | assert Page.page_size(:a8) == {0, 0, 148, 210} 64 | end 65 | 66 | test "#page_size :a9" do 67 | assert Page.page_size(:a9) == {0, 0, 105, 148} 68 | end 69 | 70 | test "#page_size :b0" do 71 | assert Page.page_size(:b0) == {0, 0, 2836, 4008} 72 | end 73 | 74 | test "#page_size :b1" do 75 | assert Page.page_size(:b1) == {0, 0, 2004, 2836} 76 | end 77 | 78 | test "#page_size :b2" do 79 | assert Page.page_size(:b2) == {0, 0, 1418, 2004} 80 | end 81 | 82 | test "#page_size :b3" do 83 | assert Page.page_size(:b3) == {0, 0, 1002, 1418} 84 | end 85 | 86 | test "#page_size :b4" do 87 | assert Page.page_size(:b4) == {0, 0, 709, 1002} 88 | end 89 | 90 | test "#page_size :b5" do 91 | assert Page.page_size(:b5) == {0, 0, 501, 709} 92 | end 93 | 94 | test "#page_size :b6" do 95 | assert Page.page_size(:b6) == {0, 0, 355, 501} 96 | end 97 | 98 | test "#page_size :b7" do 99 | assert Page.page_size(:b7) == {0, 0, 250, 355} 100 | end 101 | 102 | test "#page_size :b8" do 103 | assert Page.page_size(:b8) == {0, 0, 178, 250} 104 | end 105 | 106 | test "#page_size :b9" do 107 | assert Page.page_size(:b9) == {0, 0, 125, 178} 108 | end 109 | 110 | test "#page_size :b10" do 111 | assert Page.page_size(:b10) == {0, 0, 89, 125} 112 | end 113 | 114 | test "#page_size :c5e" do 115 | assert Page.page_size(:c5e) == {0, 0, 462, 649} 116 | end 117 | 118 | test "#page_size :comm10e" do 119 | assert Page.page_size(:comm10e) == {0, 0, 298, 683} 120 | end 121 | 122 | test "#page_size :dle" do 123 | assert Page.page_size(:dle) == {0, 0, 312, 624} 124 | end 125 | 126 | test "#page_size :executive" do 127 | assert Page.page_size(:executive) == {0, 0, 542, 720} 128 | end 129 | 130 | test "#page_size :folio" do 131 | assert Page.page_size(:folio) == {0, 0, 595, 935} 132 | end 133 | 134 | test "#page_size :ledger" do 135 | assert Page.page_size(:ledger) == {0, 0, 1224, 792} 136 | end 137 | 138 | test "#page_size :legal" do 139 | assert Page.page_size(:legal) == {0, 0, 612, 1008} 140 | end 141 | 142 | test "#page_size :letter" do 143 | assert Page.page_size(:letter) == {0, 0, 612, 792} 144 | end 145 | 146 | test "#page_size :tabloid" do 147 | assert Page.page_size(:tabloid) == {0, 0, 792, 1224} 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/gutenex.ex: -------------------------------------------------------------------------------- 1 | defmodule Gutenex do 2 | use GenServer 3 | alias Gutenex.PDF 4 | alias Gutenex.PDF.Context 5 | alias Gutenex.PDF.Text 6 | alias Gutenex.PDF.Font 7 | 8 | alias Gutenex.Geometry 9 | 10 | ####################### 11 | ## Setup ## 12 | ####################### 13 | 14 | @doc """ 15 | Starts the PDF generation server. 16 | """ 17 | def start_link(opts \\ []) do 18 | GenServer.start_link(__MODULE__, :ok, opts) 19 | end 20 | 21 | @doc """ 22 | Returns the default context and stream (empty binary) 23 | """ 24 | def init(:ok) do 25 | {:ok, [%Context{}, <<>>]} 26 | end 27 | 28 | ####################### 29 | ## Client API ## 30 | ####################### 31 | 32 | @doc """ 33 | Stop the server process 34 | """ 35 | def stop(pid) do 36 | GenServer.call(pid, :stop) 37 | end 38 | 39 | @doc """ 40 | Returns the current context 41 | """ 42 | def context(pid) do 43 | GenServer.call(pid, :context) 44 | end 45 | 46 | @doc """ 47 | Sets the current context 48 | """ 49 | def context(pid, new_context) do 50 | GenServer.cast(pid, {:context, new_context}) 51 | pid 52 | end 53 | 54 | @doc """ 55 | Sets the current page 56 | """ 57 | def set_page(pid, page) when is_integer(page) do 58 | GenServer.cast(pid, {:context, :put, {:current_page, page}}) 59 | pid 60 | end 61 | 62 | @doc """ 63 | Begin a text block 64 | """ 65 | def begin_text(pid) do 66 | GenServer.cast(pid, {:text, :begin}) 67 | pid 68 | end 69 | 70 | @doc """ 71 | Set the text position 72 | """ 73 | def text_position(pid, x_coordinate, y_coordinate) do 74 | GenServer.cast(pid, {:text, :position, {x_coordinate, y_coordinate}}) 75 | pid 76 | end 77 | 78 | @doc """ 79 | Set the text render mode 80 | """ 81 | def text_render_mode(pid, render_mode) do 82 | GenServer.cast(pid, {:text, :render_mode, render_mode}) 83 | pid 84 | end 85 | 86 | @doc """ 87 | Write text to the stream 88 | """ 89 | def write_text(pid, text_to_write) do 90 | GenServer.cast(pid, {:text, :write, text_to_write}) 91 | pid 92 | end 93 | 94 | @doc """ 95 | Write text more break line to the stream 96 | """ 97 | def write_text_br(pid, text_to_write) do 98 | GenServer.cast(pid, {:text, :write_br, text_to_write}) 99 | pid 100 | end 101 | 102 | @doc """ 103 | Set line space 104 | """ 105 | def text_leading(pid, size) do 106 | GenServer.cast(pid, {:text, :line_spacing, size}) 107 | pid 108 | end 109 | 110 | @doc """ 111 | End a text block 112 | """ 113 | def end_text(pid) do 114 | GenServer.cast(pid, {:text, :end}) 115 | pid 116 | end 117 | 118 | ##################################### 119 | # Fonts # 120 | ##################################### 121 | 122 | @doc """ 123 | Set the font 124 | """ 125 | def set_font(pid, font_name) do 126 | GenServer.cast(pid, {:font, :set, font_name}) 127 | pid 128 | end 129 | 130 | @doc """ 131 | Set the font and font size 132 | """ 133 | def set_font(pid, font_name, font_size) do 134 | GenServer.cast(pid, {:font, :set, {font_name, font_size}}) 135 | pid 136 | end 137 | 138 | @doc """ 139 | Gets the current stream 140 | """ 141 | def stream(pid) do 142 | GenServer.call(pid, :stream) 143 | end 144 | 145 | @doc """ 146 | Append to the current stream 147 | """ 148 | def append_to_stream(pid, content) do 149 | GenServer.cast(pid, {:stream, :append, content}) 150 | pid 151 | end 152 | 153 | @doc """ 154 | Export the PDF document to a binary 155 | """ 156 | def export(pid) do 157 | GenServer.call(pid, :export) 158 | end 159 | 160 | def export(pid, file_name) do 161 | File.write file_name, export(pid) 162 | pid 163 | end 164 | 165 | 166 | ##################################### 167 | # Images # 168 | ##################################### 169 | 170 | 171 | def add_image(pid, image_alias, %Imagineer.Image.PNG{}=image) do 172 | GenServer.cast(pid, {:image, :add, {image_alias, image}}) 173 | pid 174 | end 175 | 176 | @doc """ 177 | Add an image by alias 178 | """ 179 | def draw_image(pid, image_alias) do 180 | draw_image(pid, image_alias, %{}) 181 | end 182 | 183 | def draw_image(pid, image_alias, options) do 184 | GenServer.cast(pid, {:image, :write, {image_alias, options}}) 185 | pid 186 | end 187 | 188 | ##################################### 189 | # Templates # 190 | ##################################### 191 | 192 | def add_template(pid, template_alias, template_contents) do 193 | GenServer.cast(pid, {:templates, :add, {template_alias, template_contents}}) 194 | pid 195 | end 196 | 197 | def set_template(pid, template_alias) do 198 | GenServer.cast(pid, {:template, :set, {template_alias}}) 199 | pid 200 | end 201 | 202 | ####################### 203 | ## Geometry ## 204 | ####################### 205 | 206 | def move_to(pid, point_x, point_y) when is_integer(point_x) and is_integer(point_y) do 207 | move_to(pid, {point_x, point_y}) 208 | end 209 | 210 | def move_to(pid, {point_x, point_y}=point) when is_integer(point_x) and is_integer(point_y) do 211 | GenServer.cast(pid, {:geometry, :move_to, point}) 212 | pid 213 | end 214 | 215 | def line(pid, {point_start, point_finish}) do 216 | GenServer.cast(pid, {:geometry, :line, {point_start, point_finish}}) 217 | pid 218 | end 219 | 220 | def line_width(pid, width) do 221 | GenServer.cast(pid, {:geometry, :line_width, width}) 222 | pid 223 | end 224 | 225 | ####################### 226 | ## Call handlers ## 227 | ####################### 228 | 229 | @doc """ 230 | Handles stopping the server process 231 | """ 232 | def handle_call(:stop, _from, state) do 233 | {:stop, :normal, :ok, state} 234 | end 235 | 236 | @doc """ 237 | Returns the current context 238 | """ 239 | def handle_call(:context, _from, [context, stream]) do 240 | {:reply, context, [context, stream]} 241 | end 242 | 243 | @doc """ 244 | Returns the stream 245 | """ 246 | def handle_call(:stream, _from, [context, stream]) do 247 | {:reply, stream, [context, stream]} 248 | end 249 | 250 | @doc """ 251 | Export the PDF to binary format 252 | """ 253 | 254 | def handle_call(:export, _from, [context, stream]) do 255 | {:reply, PDF.export(context, stream), [context, stream]} 256 | end 257 | 258 | ####################### 259 | ## Cast handlers ## 260 | ####################### 261 | 262 | @doc """ 263 | Sets the current context 264 | """ 265 | def handle_cast({:context, new_context}, [_context, stream]) do 266 | {:noreply, [new_context, stream]} 267 | end 268 | 269 | @doc """ 270 | Appends to the stream 271 | """ 272 | def handle_cast({:stream, :append, str}, [context, stream]) do 273 | new_stream = stream <> str 274 | {:noreply, [context, new_stream]} 275 | end 276 | 277 | @doc """ 278 | Sets the current page 279 | """ 280 | def handle_cast({:context, :put, {key, value}}, [context, stream]) do 281 | new_context = Map.put context, key, value 282 | {:noreply, [new_context, stream]} 283 | end 284 | 285 | @doc """ 286 | Begin a section of text 287 | """ 288 | def handle_cast({:text, :begin}, [context, stream]) do 289 | stream = stream <> Text.begin_text 290 | {:noreply, [context, stream]} 291 | end 292 | 293 | @doc """ 294 | End a section of text 295 | """ 296 | def handle_cast({:text, :end}, [context, stream]) do 297 | stream = stream <> Text.end_text 298 | {:noreply, [context, stream]} 299 | end 300 | 301 | @doc """ 302 | Write some text! 303 | """ 304 | def handle_cast({:text, :write, text_to_write}, [context, stream]) do 305 | stream = stream <> Text.write_text(text_to_write) 306 | {:noreply, [context, stream]} 307 | end 308 | 309 | @doc """ 310 | Write some text more break line! 311 | """ 312 | def handle_cast({:text, :write_br, text_to_write}, [context, stream]) do 313 | stream = stream <> Text.write_text_br(text_to_write) 314 | {:noreply, [context, stream]} 315 | end 316 | 317 | @doc """ 318 | Set line space 319 | """ 320 | def handle_cast({:text, :line_spacing, size}, [context, stream]) do 321 | stream = stream <> Text.line_spacing(size) 322 | {:noreply, [context, stream]} 323 | end 324 | 325 | @doc """ 326 | Set the text position 327 | """ 328 | def handle_cast({:text, :position, {x_coordinate, y_coordinate}}, [context, stream]) do 329 | stream = stream <> Text.text_position(x_coordinate, y_coordinate) 330 | {:noreply, [context, stream]} 331 | end 332 | 333 | @doc """ 334 | Set the text render mode 335 | """ 336 | def handle_cast({:text, :render_mode, render_mode}, [context, stream]) do 337 | stream = stream <> Text.render_mode(render_mode) 338 | {:noreply, [context, stream]} 339 | end 340 | 341 | ##################################### 342 | # Templates # 343 | ##################################### 344 | 345 | def handle_cast({:templates, :add, {template_alias, template_contents}}, [context, stream]) do 346 | template_aliases = Map.put context.template_aliases, template_alias, template_contents 347 | {:noreply, [%Context{template_aliases: template_aliases}, stream]} 348 | end 349 | 350 | def handle_cast({:template, :set, {template_alias}}, [context, stream]) do 351 | templates = List.replace_at(context.templates, context.current_page - 1, template_alias) 352 | {:noreply, [%Context{context | templates: templates}, stream]} 353 | end 354 | 355 | ##################################### 356 | # Images # 357 | ##################################### 358 | 359 | def handle_cast({:image, :add, {image_alias, image}}, [context, stream]) do 360 | images = Map.put context.images, image_alias, image 361 | {:noreply, [%Context{context | images: images}, stream]} 362 | end 363 | 364 | def handle_cast({:image, :write, {image_alias, options}}, [context, stream]) do 365 | image = Map.get context.images, image_alias 366 | stream = stream <> Gutenex.PDF.Images.set_image(image_alias, image, options) 367 | {:noreply, [context, stream]} 368 | end 369 | 370 | ##################################### 371 | # Fonts # 372 | ##################################### 373 | 374 | @doc """ 375 | Set the font and size 376 | """ 377 | def handle_cast({:font, :set, {font_name, font_size}}, [context, stream]) do 378 | stream = stream <> Font.set_font(context.fonts, font_name, font_size) 379 | {:noreply, [context, stream]} 380 | end 381 | 382 | @doc """ 383 | Set the font 384 | """ 385 | def handle_cast({:font, :set, font_name}, [context, stream]) do 386 | stream = stream <> Font.set_font(context.fonts, font_name) 387 | {:noreply, [context, stream]} 388 | end 389 | 390 | ##################################### 391 | # Geometry # 392 | ##################################### 393 | 394 | def handle_cast({:geometry, :move_to, {point_x, point_y}}, [context, stream]) do 395 | stream = stream <> Geometry.move_to({point_x, point_y}) 396 | {:noreply, [context, stream]} 397 | end 398 | 399 | def handle_cast({:geometry, :line, {point_start, point_finish}}, [context, stream]) do 400 | stream = stream <> Geometry.Line.line({point_start, point_finish}) 401 | {:noreply, [context, stream]} 402 | end 403 | 404 | def handle_cast({:geometry, :line_width, width}, [context, stream]) do 405 | stream = stream <> Geometry.Line.line_width(width) 406 | {:noreply, [context, stream]} 407 | end 408 | end 409 | --------------------------------------------------------------------------------