Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ mudbrick-*.tar
/.direnv
test.pdf
/examples/*.pdf
/test/output/*.pdf

# macOS system files
.DS_Store
Binary file added FreeSans.otf
Binary file not shown.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[API Documentation](https://hexdocs.pm/mudbrick/Mudbrick.html)

PDF generator, beelining for:
Early-stages PDF generator, beelining for:

- PDF 2.0 support.
- In-process, pure functional approach.
Expand Down
78 changes: 78 additions & 0 deletions examples/text_wrapping_example.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Example usage of Mudbrick TextWrapper with Justification

# Basic usage with automatic text wrapping
text_block = Mudbrick.TextBlock.new(
font: font,
font_size: 12,
position: {50, 700}
)
|> Mudbrick.TextBlock.write_wrapped(
"This is a very long line of text that should be wrapped automatically to fit within the specified width constraints. It will break at word boundaries and create multiple lines as needed.",
200 # Maximum width in points
)

# Left justification (default)
text_block = Mudbrick.TextBlock.new(font: font, font_size: 12)
|> Mudbrick.TextBlock.write_wrapped(
"This text is left-aligned by default. Each line starts at the same position.",
200,
justify: :left
)

# Right justification
text_block = Mudbrick.TextBlock.new(font: font, font_size: 12)
|> Mudbrick.TextBlock.write_wrapped(
"This text is right-aligned. Each line ends at the same position on the right side.",
200,
justify: :right
)

# Center justification
text_block = Mudbrick.TextBlock.new(font: font, font_size: 12)
|> Mudbrick.TextBlock.write_wrapped(
"This text is centered. Each line is centered between the margins.",
200,
justify: :center
)

# Full justification (distribute spaces evenly)
text_block = Mudbrick.TextBlock.new(font: font, font_size: 12)
|> Mudbrick.TextBlock.write_wrapped(
"This text is fully justified. Spaces are distributed evenly between words to align both left and right margins. The last line is not justified.",
200,
justify: :justify
)

# With word breaking and justification
text_block = Mudbrick.TextBlock.new(font: font, font_size: 12)
|> Mudbrick.TextBlock.write_wrapped(
"supercalifragilisticexpialidocious and other long words that might not fit",
150,
break_words: true,
hyphenate: true,
justify: :justify
)

# With indentation and justification
text_block = Mudbrick.TextBlock.new(font: font, font_size: 12)
|> Mudbrick.TextBlock.write_wrapped(
"This is a paragraph with proper indentation and justification. Each subsequent line will be indented and justified.",
200,
indent: 20, # 20 points indentation for wrapped lines
justify: :justify
)

# Direct TextWrapper usage with justification
wrapped_lines = Mudbrick.TextWrapper.wrap_text(
"Long text here that needs to be wrapped and justified...",
font,
12,
200,
break_words: true,
justify: :justify
)

# Then manually add to TextBlock
text_block = Enum.reduce(wrapped_lines, Mudbrick.TextBlock.new(font: font), fn line, tb ->
Mudbrick.TextBlock.write(tb, line)
end)
Binary file added fonts/LibreBodoni-Bold.otf
Binary file not shown.
Binary file added fonts/LibreBodoni-BoldItalic.otf
Binary file not shown.
Binary file added fonts/LibreBodoni-Italic.otf
Binary file not shown.
Binary file added fonts/LibreBodoni-Regular.otf
Binary file not shown.
104 changes: 96 additions & 8 deletions lib/mudbrick.ex
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ defmodule Mudbrick do
- `:align` - `:left`, `:right` or `:centre`. Default: `:left`.
Note that the rightmost point of right-aligned text is the horizontal offset provided to `:position`.
The same position defines the centre point of centre-aligned text.
- `:max_width` - Maximum width in points. When set, text will automatically wrap to fit within this width.
- `:break_words` - When `max_width` is set, whether to break long words that don't fit on a line. Default: `false`.
- `:hyphenate` - When `break_words` is enabled, whether to add hyphens at word breaks. Default: `false`.
- `:indent` - Indentation in points for wrapped lines. Default: `0`.
- `:justify` - Text justification when wrapping: `:left`, `:right`, `:center`, or `:justify`. Default: `:left`.

## Individual write options

Expand Down Expand Up @@ -325,20 +330,103 @@ defmodule Mudbrick do
...> |> then(&File.write("examples/underlined_text_centre_align.pdf", &1))

<object width="400" height="130" data="examples/underlined_text_centre_align.pdf?#navpanes=0" type="application/pdf"></object>

[Text wrapping](examples/text_wrapping.pdf?#navpanes=0).

iex> import Mudbrick
...> import Mudbrick.TestHelper
...> new(fonts: %{bodoni: bodoni_regular()})
...> |> page(size: {250, 350})
...> |> text(
...> "This is a very long line of text that should be wrapped automatically to fit within the specified width constraints. It will break at word boundaries and create multiple lines as needed.",
...> font: :bodoni,
...> font_size: 12,
...> position: {10, 330},
...> max_width: 230
...> )
...> |> render()
...> |> then(&File.write("examples/text_wrapping.pdf", &1))

<object width="400" height="400" data="examples/text_wrapping.pdf?#navpanes=0" type="application/pdf"></object>

[Text wrapping with justification](examples/text_wrapping_justified.pdf?#navpanes=0).

iex> import Mudbrick
...> import Mudbrick.TestHelper
...> new(fonts: %{bodoni: bodoni_regular()})
...> |> page(size: {300, 400})
...> |> text(
...> "This text is fully justified. Spaces are distributed evenly between words to align both left and right margins. The last line is not justified.",
...> font: :bodoni,
...> font_size: 12,
...> position: {10, 380},
...> max_width: 280,
...> justify: :justify
...> )
...> |> render()
...> |> then(&File.write("examples/text_wrapping_justified.pdf", &1))

<object width="400" height="400" data="examples/text_wrapping_justified.pdf?#navpanes=0" type="application/pdf"></object>
"""

@spec text(context(), Mudbrick.TextBlock.write(), Mudbrick.TextBlock.options()) :: context()
def text(context, write_or_writes, opts \\ [])

def text({doc, _contents_obj} = context, writes, opts) when is_list(writes) do
ContentStream.update_operations(context, fn ops ->
output =
doc
|> text_block(writes, fetch_font(doc, opts))
|> TextBlock.Output.to_iodata()
# Check if max_width is set for wrapping
if Keyword.has_key?(opts, :max_width) do
max_width = Keyword.fetch!(opts, :max_width)
wrap_opts = Keyword.take(opts, [:break_words, :hyphenate, :indent, :justify])
text_block_opts = Keyword.drop(opts, [:max_width, :break_words, :hyphenate, :indent, :justify])

ContentStream.update_operations(context, fn ops ->
# Fetch font value from opts
font_opts = fetch_font(doc, text_block_opts)
tb = TextBlock.new(font_opts)

# Apply wrapping to each text element in the list
tb = Enum.reduce(writes, tb, fn
{text, write_opts}, acc_tb ->
merged_wrap_opts = Keyword.merge(wrap_opts, write_opts)
TextBlock.write_wrapped(acc_tb, text, max_width, merged_wrap_opts)

text, acc_tb ->
TextBlock.write_wrapped(acc_tb, text, max_width, wrap_opts)
end)

output = TextBlock.Output.to_iodata(tb)
output.operations ++ ops
end)
else
ContentStream.update_operations(context, fn ops ->
output =
text_block(doc, writes, fetch_font(doc, opts))
|> TextBlock.Output.to_iodata()

output.operations ++ ops
end)
end
end

output.operations ++ ops
end)
def text({doc, _contents_obj} = context, write, opts) when is_binary(write) do
# Check if max_width is set for wrapping
if Keyword.has_key?(opts, :max_width) do
max_width = Keyword.fetch!(opts, :max_width)
wrap_opts = Keyword.take(opts, [:break_words, :hyphenate, :indent, :justify])
text_block_opts = Keyword.drop(opts, [:max_width, :break_words, :hyphenate, :indent, :justify])

ContentStream.update_operations(context, fn ops ->
# Fetch font value from opts and merge with text_block_opts
font_opts = fetch_font(doc, text_block_opts)
tb = TextBlock.new(font_opts)
tb = TextBlock.write_wrapped(tb, write, max_width, wrap_opts)

output = TextBlock.Output.to_iodata(tb)
output.operations ++ ops
end)
else
text(context, [write], opts)
end
end

def text(context, write, opts) do
Expand Down Expand Up @@ -467,7 +555,7 @@ defmodule Mudbrick do
Mudbrick.TextBlock.write(acc, text, fetch_font(doc, opts))

text, acc ->
Mudbrick.TextBlock.write(acc, text, [])
Mudbrick.TextBlock.write(acc, text, fetch_font(doc, []))
end)
end

Expand Down
5 changes: 1 addition & 4 deletions lib/mudbrick/font.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,8 @@ defmodule Mudbrick.Font do
offset = normal_width - width_when_kerned
{Mudbrick.to_hex(glyph_id), offset}

{glyph_id, {:std_width, _, _, _, _}} ->
{glyph_id, {:std_width, _, _, _width, _}} ->
Mudbrick.to_hex(glyph_id)

{glyph_id, {:pos, _, _, _, _}} ->
Mudbrick.to_hex(glyph_id)
end)
end

Expand Down
93 changes: 69 additions & 24 deletions lib/mudbrick/image.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
defmodule Mudbrick.Image do
@moduledoc """
Back-compat image facade and helpers.

This module:
- Detects image format and delegates to `Mudbrick.Images.Jpeg` or `Mudbrick.Images.Png`
- Provides helpers to register images as PDF objects on a `Mudbrick.Document`
- Implements a legacy object serialiser (used only for older code paths)
"""
@type t :: %__MODULE__{
file: iodata(),
resource_identifier: atom(),
Expand All @@ -23,7 +31,8 @@ defmodule Mudbrick.Image do
:width,
:height,
:bits_per_component,
:filter
:filter,
dictionary: %{}
]

defmodule AutoScalingError do
Expand All @@ -41,47 +50,83 @@ defmodule Mudbrick.Image do
alias Mudbrick.Document
alias Mudbrick.Stream

@doc false
@spec new(Keyword.t()) :: t()
@doc """
Create an image object by detecting the format and delegating to
`Mudbrick.Images.Jpeg` or `Mudbrick.Images.Png`.

Required options:
- `:file` – raw image bytes
- `:resource_identifier` – atom reference like `:I1`
- `:doc` – document context (used by PNG indexed/alpha paths)

Returns a format-specific struct implementing `Mudbrick.Object`.
"""
@spec new(Keyword.t()) :: t() | Mudbrick.Images.Jpeg.t() | Mudbrick.Images.Png.t() | {:error, term()}
def new(opts) do
struct!(
__MODULE__,
Keyword.merge(
opts,
file_dependent_opts(ExImageInfo.info(opts[:file]))
)
)
case identify_image(opts[:file]) do
:jpeg ->
Mudbrick.Images.Jpeg.new(opts)

:png ->
Mudbrick.Images.Png.new(opts)

_else ->
{:error, :image_format_not_recognised}
end

end

@doc false
@doc """
Identify image type by magic bytes.
Returns `:jpeg`, `:png`, or `{:error, :image_format_not_recognised}`.
"""
@spec identify_image(binary()) :: :jpeg | :png | {:error, :image_format_not_recognised}
def identify_image(<<255, 216, _rest::binary>>), do: :jpeg
def identify_image(<<137, 80, 78, 71, 13, 10, 26, 10, _rest::binary>>), do: :png
def identify_image(_), do: {:error, :image_format_not_recognised}

@doc """
Add images to a document object table, returning the updated document and a
map of human names to registered image objects.

The images map is `%{name => image_bytes}`. Each is added via `new/1` and any
additional objects (e.g., PNG palette or SMask) are appended to the document.
"""
@spec add_objects(Mudbrick.Document.t(), %{optional(atom() | String.t()) => binary()}) ::
{Mudbrick.Document.t(), map()}
def add_objects(doc, images) do
{doc, image_objects, _id} =
for {human_name, image_data} <- images, reduce: {doc, %{}, 0} do
{doc, image_objects, id} ->
{doc, image} =
Document.add(
doc,
new(file: image_data, resource_identifier: :"I#{id + 1}")
new(file: image_data, doc: doc, resource_identifier: :"I#{id + 1}")
)

doc =
case Enum.count(image.value.additional_objects) do
0 ->
doc

_ ->
{doc, _additional_objects} = Enum.reduce(image.value.additional_objects, {doc, []}, fn additional_object, {doc, _objects} ->
{doc, _obj} = Document.add(doc, additional_object)
{doc, nil}
end)

# doc = Document.add(doc, additional_objects)
# end
# {doc, _additional_objects} = Document.add(doc, image.value.additional_objects)
doc
end

{doc, Map.put(image_objects, human_name, image), id + 1}
end

{doc, image_objects}
end

defp file_dependent_opts({"image/jpeg", width, height, _variant}) do
[
width: width,
height: height,
filter: :DCTDecode,
bits_per_component: 8
]
end

defp file_dependent_opts({"image/png", _width, _height, _variant}) do
raise NotSupported, "PNGs are currently not supported"
end

defimpl Mudbrick.Object do
def to_iodata(image) do
Expand Down
Loading