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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ As can be seen above, granular APIs are provided in `PDFIO` that can be used in
- Extracting document metadata information ([`pdDocGetInfo`](@ref))
- Validation of signatures in a PDF document ([`pdDocValidateSignatures`](@ref))
- Extracting fonts and font attributes ([`pdPageGetFonts`](@ref), [`pdFontIsItalic`](@ref) etc.)
- Extracting files embedded in a document ([`pdDocExtractAttachments`](@ref)), e.g. `pdDocExtractAttachments("file.pdf")`
3. Access low level PDF objects and obtain information when high level APIs do not exist.

The [Architecture and Design](@ref) discusses some of these scenarios.
Expand Down
8 changes: 5 additions & 3 deletions docs/src/arch.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,11 @@ methods of the PD layer.

#### Example

Extracting files embedded inside a PDF document is not currently available as a
PD Layer functionality. However, the same has been achieved using COS layer
functions. The code is available in the automated test cases as well.
Extracting files embedded inside a PDF document is available in the PD Layer as
[`pdDocExtractAttachments`](@ref). Before that, COS layer functions had to be
used, as in the sketch below. It is kept to illustrate the COS layer: it only
visits the page annotations and it trusts the file name stored in the PDF, so
use the PD Layer function for real work.

```julia
function pdfhlp_extract_doc_attachment_files (filename, dir=tempdir())
Expand Down
6 changes: 6 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ pdDocGetPageLabel
pdDocGetOutline
pdDocHasSignature
pdDocValidateSignatures
PDAttachment
pdDocGetAttachments
pdDocExtractAttachments
pdAttachmentGetName
pdAttachmentGetData
pdAttachmentExtract
pdPageGetContents
pdPageIsEmpty
pdPageGetCosObject
Expand Down
7 changes: 5 additions & 2 deletions src/CosDoc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,11 @@ attach_object(doc::CosDocImpl, indstm::CosIndirectObject{CosStream})=
attach_object(doc,indstm.obj)

function attach_object(doc::CosDocImpl, stm::CosStream)
tmpfile = get(get(stm, cn"F"))
push!(doc.tmpfiles, String(tmpfile))
tmpfile = String(get(get(stm, cn"F")))
# A stream can name an external file of the PDF author's choosing in /F. Only
# the files written by the parser itself are removed when the document is closed.
startswith(abspath(tmpfile), joinpath(get_tempdir(), "")) &&
push!(doc.tmpfiles, tmpfile)
return nothing
end

Expand Down
1 change: 1 addition & 0 deletions src/PD.jl
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ include("PDPage.jl")
include("PDFontTables.jl")
include("PDOutline.jl")
include("PDSignature.jl")
include("PDAttachment.jl")

end
279 changes: 279 additions & 0 deletions src/PDAttachment.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
export PDAttachment,
pdDocGetAttachments,
pdAttachmentGetName,
pdAttachmentGetData,
pdAttachmentExtract,
pdDocExtractAttachments

using ..Common, ..Cos

"""
```
PDAttachment
```
A file embedded inside a PDF document. It's either listed in the document level
`EmbeddedFiles` name tree or attached to a page with a `FileAttachment`
annotation.

See [`pdDocGetAttachments`](@ref).
"""
struct PDAttachment
name::String
stream::CosObject
end

show(io::IO, att::PDAttachment) = print(io, "PDAttachment(", repr(att.name), ")")

"""
```
pdAttachmentGetName(att::PDAttachment) -> String
```
Returns the file name of the attachment as recorded in the PDF document. The
name is not sanitized. It should not be used as a path without care. See
[`pdAttachmentExtract`](@ref).
"""
pdAttachmentGetName(att::PDAttachment) = att.name

"""
```
pdAttachmentGetData(att::PDAttachment) -> Vector{UInt8}
```
Reads and decodes the contents of the attachment.
"""
function pdAttachmentGetData(att::PDAttachment)
bufstm = get(att.stream)
try
return read(bufstm)
finally
util_close(bufstm)
end
end

# Attachment file names are not trusted. Only a plain file name is retained.
function sanitize_filename(name::AbstractString)
name = String(last(split(replace(name, '\\' => '/'), '/')))
name = replace(name, r"[\x00-\x1f\x7f-\x9f<>:\"|?*]" => "_")
name = String(rstrip(strip(name), ['.', ' ']))
# Windows device names are unusable as file names, even with an extension.
occursin(r"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(\..*)?$"i, name) &&
(name = "_" * name)
return name in ("", ".", "..") ? "attachment" : name
end

# Creates a new file in `dir`. `O_EXCL` makes the creation atomic, so an existing
# file or a symbolic link is never opened. A numeric suffix is tried instead.
function create_new_file(dir::AbstractString, name::String)
base, ext = splitext(name)
for i = 0:typemax(Int16)
path = joinpath(dir, i == 0 ? name : string(base, " (", i, ")", ext))
try
flags = Base.Filesystem.JL_O_WRONLY | Base.Filesystem.JL_O_CREAT |
Base.Filesystem.JL_O_EXCL
# 0o666 is subject to the umask of the user, like a regular `write`.
return path, Base.Filesystem.open(path, flags, 0o666)
catch e
(e isa Base.IOError && e.code == Base.UV_EEXIST) || rethrow()
end
end
error("Could not find an unused file name for $name in $dir")
end

"""
```
pdAttachmentExtract(att::PDAttachment, dir::AbstractString=".") -> String
```
Writes the attachment into the directory `dir` and returns the path of the file
written. Only the file name part of the attachment name is used. Existing files
are never overwritten. A numeric suffix is added to the file name instead.
"""
function pdAttachmentExtract(att::PDAttachment, dir::AbstractString=".")
data = pdAttachmentGetData(att) # No file is created if decoding fails.
mkpath(dir)
path, io = create_new_file(dir, sanitize_filename(att.name))
try
write(io, data)
close(io) # Can fail while flushing, hence inside the `try`.
catch
try close(io) catch end # The original error is the one to report.
rm(path; force=true) # Do not leave a partial file behind.
rethrow()
end
return path
end

# Text strings are UTF-16BE or UTF-8 with a byte order mark, else PDFDocEncoding.
function pdf_text(str::CosString, fallback::String="attachment")
b = Vector{UInt8}(str)
if length(b) >= 2 && b[1] == 0xfe && b[2] == 0xff
# An odd number of bytes is not valid UTF-16: do not truncate the name.
isodd(length(b)) && return fallback
u16 = UInt16[(UInt16(b[i]) << 8) | b[i+1] for i = 3:2:length(b)-1]
s = transcode(String, u16)
# An unpaired surrogate transcodes without error but leaves invalid
# UTF-8 bytes behind (e.g. a lone high surrogate).
return isvalid(s) ? s : fallback
elseif length(b) >= 3 && b[1:3] == UInt8[0xef, 0xbb, 0xbf]
return isvalid(String, b[4:end]) ? String(b[4:end]) : fallback
end
return String(CDTextString(PDFEncodingToUnicode(b)))
end

is_internal_stream(stm::CosStream) = stm.isInternal
is_internal_stream(stm::CosIndirectObject{CosStream}) = stm.obj.isInternal

# The file specification dictionary is resolved to the embedded file stream.
function attachment_from_filespec(cosdoc::CosDoc, fs::CosObject,
fallback::String="attachment")
fsdict = cosDocGetObject(cosdoc, fs)
fsdict isa IDD{CosDict} || return nothing
ef = cosDocGetObject(cosdoc, fsdict, cn"EF")
ef isa IDD{CosDict} || return nothing
stm = CosNull
for key in (cn"F", cn"UF", cn"Unix", cn"Mac", cn"DOS") # Legacy keys are deprecated.
stm = cosDocGetObject(cosdoc, ef, key)
stm === CosNull || break
end
stm isa IDD{CosStream} || return nothing
# A stream whose /F was supplied by the PDF itself (as opposed to one the
# parser wrote out internally) refers to an arbitrary local file path.
# Reading it would let a crafted PDF exfiltrate files readable by this
# process, so such streams are not treated as attachments.
is_internal_stream(stm) || return nothing
name = fallback
for key in (cn"UF", cn"F")
nobj = cosDocGetObject(cosdoc, fsdict, key)
nobj isa CosIndirectObject && (nobj = nobj.obj)
if nobj isa CosString
name = pdf_text(nobj, fallback)
break
end
end
return PDAttachment(name, stm)
end

# Walks a name tree calling `fn(key, value)` on every entry. `/Names` and `/Kids`
# may be indirect objects, hence they are resolved through the document.
function collect_nametree!(fn::Function, cosdoc::CosDoc, node::IDD{CosDict},
visited::Set{CosIndirectObjectRef})
names = cosDocGetObject(cosdoc, node, cn"Names")
if names isa IDD{CosArray}
v = get(names)
for i = 1:2:length(v)-1
key = cosDocGetObject(cosdoc, v[i])
fn(key isa CosString ? pdf_text(key) : "attachment", v[i+1])
end
end
kids = cosDocGetObject(cosdoc, node, cn"Kids")
kids isa IDD{CosArray} || return
for kid in get(kids)
if kid isa CosIndirectObjectRef
kid in visited && continue
push!(visited, kid)
end
kidobj = cosDocGetObject(cosdoc, kid)
kidobj isa IDD{CosDict} && collect_nametree!(fn, cosdoc, kidobj, visited)
end
end

"""
```
pdDocGetAttachments(doc::PDDoc) -> Vector{PDAttachment}
```
Returns all the files embedded in the document. The files listed in the
`EmbeddedFiles` name tree are followed by the ones attached to the pages with
`FileAttachment` annotations. An embedded file referred in both the places is
returned once. Encrypted documents are not supported.

# Example
```
julia> pdDocGetAttachments(doc)
2-element Vector{PDAttachment}:
PDAttachment("hello.txt")
PDAttachment("data.bin")
```
"""
function pdDocGetAttachments(doc::PDDoc)
cosdoc = pdDocGetCosDoc(doc)
atts, seen = PDAttachment[], Set{Any}()
function add!(fs, fallback="attachment")
att = attachment_from_filespec(cosdoc, fs, fallback)
att === nothing && return
# Same embedded file stream can be referenced from multiple places.
id = att.stream isa CosIndirectObject ?
(att.stream.num, att.stream.gen) : objectid(att.stream)
id in seen && return
push!(seen, id)
push!(atts, att)
end

names = pdDocGetNamesDict(doc)
if names isa IDD{CosDict}
ef = cosDocGetObject(cosdoc, names, cn"EmbeddedFiles")
if ef isa IDD{CosDict}
visited = Set{CosIndirectObjectRef}()
collect_nametree!(cosdoc, ef, visited) do key, fs
add!(fs, key)
end
end
end

for i = 1:pdDocGetPageCount(doc)
cospage = pdPageGetCosObject(pdDocGetPage(doc, i))
annots = cosDocGetObject(cosdoc, cospage, cn"Annots")
annots isa IDD{CosArray} || continue
for annot in get(annots)
adict = cosDocGetObject(cosdoc, annot)
adict isa IDD{CosDict} || continue
cosDocGetObject(cosdoc, adict, cn"Subtype") === cn"FileAttachment" ||
continue
add!(cosDocGetObject(cosdoc, adict, cn"FS"))
end
end
return atts
end

"""
```
pdDocExtractAttachments(doc::PDDoc, dir::AbstractString=".") -> Vector{String}
```
Extracts all the files embedded in the document into the directory `dir`
(default is the current directory), creating it if required. Returns the paths
of the files written. See [`pdAttachmentExtract`](@ref).

# Example
```
julia> doc = pdDocOpen("invoice.pdf");

julia> pdDocExtractAttachments(doc)
1-element Vector{String}:
"./invoice.xml"
```
"""
function pdDocExtractAttachments(doc::PDDoc, dir::AbstractString=".")
return [pdAttachmentExtract(att, dir) for att in pdDocGetAttachments(doc)]
end

"""
```
pdDocExtractAttachments(filepath::AbstractString, dir::AbstractString=".") -> Vector{String}
```
Convenience method that opens the PDF document at `filepath`, extracts every
file embedded in it into the directory `dir` (default is the current
directory), and closes the document. Returns the paths of the files written.
See [`pdDocExtractAttachments(::PDDoc, ::AbstractString)`](@ref).

# Example
```
julia> pdDocExtractAttachments("invoice.pdf")
1-element Vector{String}:
"./invoice.xml"
```
"""
function pdDocExtractAttachments(filepath::AbstractString, dir::AbstractString=".")
doc = pdDocOpen(filepath)
try
return pdDocExtractAttachments(doc, dir)
finally
pdDocClose(doc)
end
end
8 changes: 7 additions & 1 deletion src/PDFIO.jl
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export PDDoc,
pdDocGetOutline,
pdDocHasSignature,
pdDocValidateSignatures,
pdDocGetAttachments,
pdDocExtractAttachments,
PDPage,
pdPageGetContents,
pdPageIsEmpty,
Expand Down Expand Up @@ -52,7 +54,11 @@ export PDDoc,
PDOutline,
PDDestination,
PDOutlineItem,
pdOutlineItemGetAttr
pdOutlineItemGetAttr,
PDAttachment,
pdAttachmentGetName,
pdAttachmentGetData,
pdAttachmentExtract

using .Cos
export CosDoc,
Expand Down
Binary file added test/files/attachments.pdf
Binary file not shown.
Binary file added test/files/attachments_edge.pdf
Binary file not shown.
Loading