PdfElixide.Editor changes page structure, rotates and crops pages, covers
regions and adds attachments. Changes stay in memory until you write the
document. The Forms guide covers filling fields and flattening
annotations; the Encryption guide covers password-protected
output; Merging and splitting covers combining
documents and writing parts of one to new files.
Saving edits
Use PdfElixide.Editor.save/3 with its default incremental: false, or
PdfElixide.Editor.to_binary/2, to write page edits and attachments. Writing
leaves the editor open for further changes; PdfElixide.Editor.close/1 discards
any unsaved edits.
An incremental save carries field values and document information, and refuses
everything else. PdfElixide.Editor.save(editor, path, incremental: true)
appends an update to a verbatim copy of the original file. Only two kinds of
change reach that update: form field values written with
PdfElixide.Form.put_value/3 — see Saving for that workflow
— and the /Info entries the PdfElixide.Editor.set_title/2 family sets.
Everything else counts as pending:
- page deletions and moves
- page rotations
- page media and crop boxes
- erased regions and queued redaction regions
- redaction marks
- annotation and form flatten marks
- attachments
If the editor holds any of those, the save returns
{:error, %PdfElixide.Error{reason: :unsupported}} naming them, and writes no
file.
To withdraw pending changes, use PdfElixide.Editor.clear_erase_regions/2 for a
page's erased regions, PdfElixide.Editor.unmark_redactions/2 for its redaction
mark, or move pages back into their original order. Incremental saving becomes
available once no unsupported changes remain. Deletions and the other changes
listed above cannot be withdrawn; reopen the source or write a full rewrite.
Setting rotation or boxes counts as pending even if the value is unchanged.
Restoring the original value or calling PdfElixide.Editor.rotate_page_by/3
with 0 still prevents incremental saving. Only page order is compared with the
source.
Marks also count when there is nothing to draw: PdfElixide.Form.flatten/1 on
a document with no AcroForm and PdfElixide.Editor.mark_redactions/2 on a page
without /Redact annotations both prevent incremental saving.
A full rewrite in between does not lift the refusal. An incremental update is appended to the original file whatever was written since, so the pending change would still be missing from it.
An editor built with PdfElixide.Editor.from_binary/2 cannot save
incrementally, and neither can one created with
PdfElixide.Editor.from_markdown/2, PdfElixide.Editor.from_html/2,
PdfElixide.Editor.from_plain_text/2 or PdfElixide.Editor.from_images/2.
None of them has a source file to copy, so incremental: true returns
{:error, %PdfElixide.Error{reason: :unsupported}} even with no edits or only
field values. Use PdfElixide.Editor.open/2 for incremental saving, or write a
full rewrite.
An editor that has merged another document, or been converted to PDF/A, cannot save incrementally. Both rebuild the editor from the resulting document, which has no source file to append to; see Merging documents and the PDF/A conversion guide.
An encrypted source cannot be saved incrementally. The call returns
{:error, %PdfElixide.Error{reason: :unsupported}}; see
Incremental saves.
Destructive redaction requires a full rewrite. Once
PdfElixide.Editor.apply_redactions/1 or PdfElixide.Editor.sanitize/1 has
run, incremental: true is refused because it would leave removed content
readable. A write with garbage_collect: false after a sanitize is also
refused. See What is refused, and why.
PdfElixide.Editor.to_binary/2 refuses incremental: true with
{:error, %PdfElixide.Error{reason: :invalid_pdf}} regardless of pending edits.
After PdfElixide.Editor.sanitize/1, requesting garbage_collect: false
takes precedence and returns :unsupported instead. Neither writing function
accepts encryption with incremental: true; see Encryption.
Page structure
PdfElixide.Editor.delete_page/2 and PdfElixide.Editor.move_page/3 change
which pages the document has and in what order. Indices are zero-based and count
the pages as currently edited, so PdfElixide.Editor.page_count/1 is what they
are bounded by, and it moves as soon as a page is deleted rather than waiting
for a save:
alias PdfElixide.Document
alias PdfElixide.Editor
"path/to/report.pdf"
|> Editor.open!()
|> Editor.move_page!(0, 2)
|> Editor.delete_page!(0)
|> Editor.save!("reordered.pdf")
|> Editor.close()
#=> :okPdfElixide.Editor.keep_pages/2 does several of those at once: it keeps the
listed pages in the order given and drops the rest. Pending edits such as a
rotation stay with their pages. To write some pages to a new document instead of
changing this one, see
Splitting a document.
There are two further limitations:
Deleting a page is not redaction. It removes the page from the document's
page tree, so the written file has one fewer page and nothing displays it — but
the page's objects and content stream are still in that file as unreferenced
data, with garbage_collect: true as much as without. Anyone reading the bytes
can recover them. The same is true of a page PdfElixide.Editor.keep_pages/2
drops. Do not use either, or extraction, to remove confidential content; see
Extraction is not redaction.
If every page is deleted, reopening the written file can discover those orphaned
page objects again, so this is not a way to create a safely page-less PDF
either.
Bookmarks and links are not remapped. Nothing updates the outline, link annotations, named destinations, page labels, the structure tree or a form field's widget references, so entries pointing at a page that was deleted or moved are left pointing where they were.
Page rotation
PdfElixide.Editor.set_rotation/3 turns a page to an absolute angle,
PdfElixide.Editor.rotate_page_by/3 adds a relative rotation, and
PdfElixide.Editor.rotate_all_by/2 does that to every page.
PdfElixide.Editor.rotation/2 reads the angle back, pending changes included:
"path/to/scan.pdf"
|> Editor.open!()
|> Editor.rotate_all_by!(90)
|> Editor.set_rotation!(0, 0)
|> Editor.save!("upright.pdf")
|> Editor.close()
#=> :okAngles are quadrants. PdfElixide.Editor.set_rotation/3 takes 0, 90,
180 or 270, and the two relative calls take any multiple of 90 — negative
to turn anticlockwise, past 360 to wrap. Anything else raises
FunctionClauseError rather than being rounded to the nearest quadrant. An
invalid stored /Rotate reads as 0 before the delta is added, so turning a
45 page by 90 lands on 90, not 135.
A rotation belongs to the page rather than to the position, so it follows the
page through PdfElixide.Editor.move_page/3 and survives the deletion of
another page.
Rotation only turns the page as a viewer displays it. Nothing re-lays out the
content, and the page's /MediaBox is not swapped, so a 90-rotated portrait
page still reports portrait dimensions. See Saving edits for
the incremental-save refusal.
Page boxes
A page has two boxes that matter to a viewer: the /MediaBox is the sheet the
page is imposed on, and the optional /CropBox is the part of that sheet a
viewer displays and prints. PdfElixide.Editor.media_box/2 and
PdfElixide.Editor.crop_box/2 read them, pending changes included;
PdfElixide.Editor.set_media_box/3 and PdfElixide.Editor.set_crop_box/3 set
one page's, and PdfElixide.Editor.crop_margins/2 sets every page's crop box by
insetting its media box:
"path/to/scan.pdf"
|> Editor.open!()
|> Editor.crop_margins!(left: 36, right: 36, top: 24, bottom: 24)
|> Editor.save!("trimmed.pdf")
|> Editor.close()
#=> :okEvery box is a PdfElixide.Geometry.Rect in the page's raw, unrotated user
space, so a box read from PdfElixide.Document.Page.media_box/1, or a bbox
from an extractor that reports in that space, can be handed straight back. For
rotated extractor boxes, see Rotated pages and extracted
geometry; only boxes
reported in the displayed frame should pass through
PdfElixide.Geometry.Rect.to_user_space/3. A reversed rectangle is normalized
before it is written, and the getters report the box as it will land in the
file.
Cropping hides content; it does not remove it. Whatever lies outside the crop box is still in the written file, and anything that reads the content stream still finds it. Text extraction is not such a reader — see What the crop box hides from extraction — so cropping is a way to hide text from extraction but not a way to delete it; use Redaction for that. A viewer clips the crop box to the media box, so a crop box larger than the media box shows the whole page, and nothing here checks one against the other.
Setting the media box leaves an existing crop box alone. A page whose crop
box was declared by the document keeps it after
PdfElixide.Editor.set_media_box/3, wherever the new media box lands; call
PdfElixide.Editor.set_crop_box/3 as well when both should change. Once a page
has a crop box it cannot be removed — set it equal to the media box to show the
whole page. The page's content is neither moved nor scaled by either setter.
PdfElixide.Editor.crop_margins/2 measures from each page's media box as
PdfElixide.Editor.media_box/2 reports it, so a media box set earlier in the
same session counts, and it replaces any crop box a page already has. It reads
every page before changing any: if one page has no readable /MediaBox the call
returns {:error, %PdfElixide.Error{reason: :invalid_pdf}}, and if the margins
would leave a page with no area it returns
{:error, %PdfElixide.Error{reason: :other}} naming the page — in both cases
with nothing changed. Give such a page a media box with
PdfElixide.Editor.set_media_box/3 first.
A box belongs to the page rather than to the position, so it follows the page
through PdfElixide.Editor.move_page/3 and survives the deletion of another
page. Where a box is inherited from the page tree, see Which ancestor an
inherited box comes
from. See Saving
edits for the incremental-save refusal.
Erasing regions
PdfElixide.Editor.erase_region/3 paints a white rectangle over part of a page
as the file is written, and PdfElixide.Editor.erase_regions/3 does that for
several rectangles in one call. A PdfElixide.Geometry.Rect from an extractor
can be handed straight back:
doc = Document.open!("path/to/report.pdf")
[span | _] = Document.spans!(doc, 0)
Document.close(doc)
"path/to/report.pdf"
|> Editor.open!()
|> Editor.erase_region!(0, span.bbox)
|> Editor.save!("covered.pdf")
|> Editor.close()
#=> :okErasing is not redaction. Covered text and images remain in the written
file; PdfElixide.Document.text/1 still returns the covered words. Do not use
it to remove confidential content — PdfElixide.Editor.apply_redactions/1 is
the call that removes any, and it removes covered text only, leaving images
and vector graphics where they were. The Redaction guide covers
what each one does and does not take.
An erase on a page that PdfElixide.Editor.apply_redactions/1 then rewrites
is silently dropped — the whiteout never reaches the output and whatever it
covered stays visible. Erase in a separate editor, after the redactions have
been written; see
It discards other pending overlays on the page.
The rectangle covers page content only. Annotations — form widgets, stamps,
links — remain above it, even when flattened on the same editor. To hide an
annotation whose rectangle is rect, flatten, write, reopen what was written,
and erase there:
flattened =
"path/to/form.pdf"
|> Editor.open!()
|> Editor.flatten_annotations!()
bytes = Editor.to_binary!(flattened)
Editor.close(flattened)
bytes
|> Editor.from_binary!()
|> Editor.erase_region!(0, rect)
|> Editor.save!("covered.pdf")
|> Editor.close()
#=> :okA page with no content stream is left as it is. A page whose content streams are
stored as an indirect array — a /Contents entry that refers to an array object
rather than holding one — cannot take an overlay, and
PdfElixide.Editor.erase_region/3 returns
{:error, %PdfElixide.Error{reason: :unsupported}} for it. Such a page can
still be rotated, moved, deleted and saved.
Coordinates usually use the page's raw, unrotated user space, as reported by
PdfElixide.Document.chars/1, PdfElixide.Document.spans/1 and
PdfElixide.Document.paths/1. Rotated extractor boxes follow the same
conversion rules as crop boxes above.
The whiteout inherits the graphics state left by the page content. An active
transformation can move it, a clipping path can hide part or all of it, and an
unfinished path can cause extra content to be covered. These cases still report
success. A redaction block does not behave this way; see
Where the block lands.
PdfElixide.Document.rects/2 shows placement, not visibility, and may omit a
fill combined with an unfinished path; PdfElixide.Document.paths/2 reports the
combined shapes. For a document you did not produce, render the written result
to verify coverage.
A region belongs to the page rather than to the position, so it follows the page
through PdfElixide.Editor.move_page/3 and survives the deletion of another
page.
PdfElixide.Editor.clear_erase_regions/2 discards the regions pending on a
page. It does not reset PdfElixide.Editor.modified?/1, but it does lift the
incremental-save refusal for that page. See Saving edits.
Attachments
PdfElixide.Editor.embed_file/4 attaches a file to the document — a spreadsheet
behind a report, the source data behind a chart — and
PdfElixide.Editor.embedded_files/1 lists what the document will carry, pending
attachments included:
"path/to/report.pdf"
|> Editor.open!()
|> Editor.embed_file!("figures.csv", csv, description: "Chart data")
|> Editor.save!("report-with-data.pdf")
|> Editor.close()
#=> :okA document that already has a name tree is refused with
{:error, %PdfElixide.Error{reason: :unsupported}}, because attaching a file
cannot preserve that tree's existing attachments, named destinations or
document-level JavaScript. To attach several files, add them in the same editing
session. The error message names the entries that would be lost.
A PdfElixide.Editor.sanitize/1 that emptied the name tree lifts the refusal,
so a document's attachments can be replaced rather than only removed:
editor = Editor.open!("path/to/received.pdf")
Editor.sanitize!(editor)
Editor.embed_file!(editor, "figures.csv", csv)
Editor.save!(editor, "resupplied.pdf")
Editor.close(editor)This works only when nothing is left in the tree. A sanitize that kept an entry
— one run with remove_javascript: false, say — still refuses, and names the
entry it is protecting. A name tree that cannot be read is refused either way.
See Saving edits for the incremental-save refusal.
No media type is written for an attachment. PdfElixide.Document.EmbeddedFile
reads one when another producer declared it, but this editor cannot set one.
Document information
PdfElixide.Editor.set_title/2, PdfElixide.Editor.set_author/2,
PdfElixide.Editor.set_subject/2, PdfElixide.Editor.set_keywords/2,
PdfElixide.Editor.set_creator/2, PdfElixide.Editor.set_producer/2,
PdfElixide.Editor.set_creation_date/2 and PdfElixide.Editor.set_mod_date/2
change the /Info dictionary, and PdfElixide.Editor.metadata/1 reads it with
pending changes applied:
"path/to/report.pdf"
|> Editor.open!()
|> Editor.set_title!("Quarterly report")
|> Editor.set_author!("Ada Lovelace")
|> Editor.set_mod_date!(DateTime.utc_now())
|> Editor.save!("titled.pdf")
|> Editor.close()
#=> :okPassing nil removes an entry. Each setter takes one string; keywords are
stored as a single comma-separated string, which is how
PdfElixide.Document.Metadata reads them back.
What a write carries
Every write, full rewrite or incremental, carries the source's title, author,
subject, keywords, creator, producer and both dates whether or not you set any
of them, re-encoded as described below — /Info is one of the two things an
incremental update carries, per Saving edits. Two things do not
survive: /Trapped, which cannot be set — PdfElixide.Editor.metadata/1
reports the source's value until something clears it — and any non-standard
/Info key.
PdfElixide.Editor.sanitize/1 is what clears it. With its default
scrub_metadata: true none of those values are carried, /Trapped included,
and PdfElixide.Editor.metadata/1 answers nil for every field afterwards;
see Sanitizing. Nothing is stamped for you: the
producer stays whatever the source named, and /ModDate is only what you set,
so set it yourself when a reader will look at it. A whitespace-only value is
written but reads back as nil.
Text encoding
ASCII is written as it stands. Anything else is written as UTF-8 behind a
byte-order mark, the PDF 2.0 spelling (ISO 32000-2 §7.9.2.2), which
PdfElixide.Document.metadata/1 decodes and PDF 2.0-aware readers such as
Poppler decode too. The file's declared version is not raised, so a reader that
predates PDF 2.0 may show the mark and the raw bytes instead. UTF-16 cannot be
produced.
Dates
PdfElixide.Editor.set_creation_date/2 and PdfElixide.Editor.set_mod_date/2
take a DateTime or a PDF date string.
A DateTime is written as D:YYYYMMDDHHMMSS followed by Z for UTC or by the
offset as +HH'mm' / -HH'mm'. Fractional seconds are dropped. An offset that
is not a whole number of minutes cannot be spelled, so such a value is written
in UTC and the instant is preserved rather than the wall clock.
A string must be a well-formed PDF date in full; a value read from
PdfElixide.Editor.metadata/1 qualifies. A malformed string, or one with
anything after the date, raises ArgumentError.
XMP and document identifiers
The XMP packet is not updated: PdfElixide.Document.xmp_metadata/1 reads back
what the source carried, and a viewer that prefers XMP over /Info shows those
values rather than the ones you set. The exception is an encrypted write, which
the Encryption guide covers.
The trailer's /ID is dropped by a full rewrite, and a write emits one only
when :encryption is given.