Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "ely-pdf-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely-ipc.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
ely-pdf-engine = { path = ".", features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
objc2.workspace = true
|
||||
objc2-foundation = { workspace = true, features = ["NSArray"] }
|
||||
objc2-pdf-kit = { workspace = true, features = ["PDFPage", "PDFAnnotation", "PDFAnnotationUtilities"] }
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Builds the MuPDF shim into the worker binary only.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("set by cargo")).join("../..");
|
||||
let mupdf = std::env::var_os("ELY_MUPDF_DIR").map_or_else(|| root.join("engines/mupdf-build"), PathBuf::from);
|
||||
// One stamp for client and worker; stale workers refused.
|
||||
let mut stamp: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for source in [
|
||||
"shim/ely_mupdf.c",
|
||||
"shim/ely_pages.c",
|
||||
"shim/ely_insert.c",
|
||||
"shim/ely_text.c",
|
||||
"shim/ely_write.c",
|
||||
"shim/ely_place.c",
|
||||
"shim/ely_annot.c",
|
||||
"shim/ely_form.c",
|
||||
"shim/ely_ocr.c",
|
||||
"shim/ely_outline.c",
|
||||
"shim/ely_meta.c",
|
||||
"shim/ely_redact.c",
|
||||
"shim/ely_alt.c",
|
||||
"shim/ely_clean.c",
|
||||
"shim/ely_secure.c",
|
||||
"shim/ely_sign.c",
|
||||
"shim/ely_output.c",
|
||||
"shim/ely_shim.h",
|
||||
"src/proto.rs",
|
||||
"src/bin/ely-pdf-worker/main.rs",
|
||||
"src/bin/ely-pdf-worker/ffi.rs",
|
||||
"src/bin/ely-pdf-worker/edit.rs",
|
||||
"src/bin/ely-pdf-worker/content.rs",
|
||||
"src/edit.rs",
|
||||
"src/content.rs",
|
||||
"src/marks.rs",
|
||||
"src/bin/ely-pdf-worker/marks.rs",
|
||||
"src/bin/ely-pdf-worker/ocr.rs",
|
||||
"src/bin/ely-pdf-worker/structure.rs",
|
||||
"src/bin/ely-pdf-worker/redact.rs",
|
||||
"src/redact.rs",
|
||||
"src/structure.rs",
|
||||
"src/ocr.rs",
|
||||
"src/bin/ely-pdf-worker/secure.rs",
|
||||
"src/secure.rs",
|
||||
"src/bin/ely-pdf-worker/sign.rs",
|
||||
"src/sign.rs",
|
||||
"src/bin/ely-pdf-worker/output.rs",
|
||||
"src/output.rs",
|
||||
"../ely-ipc/src/lib.rs",
|
||||
] {
|
||||
println!("cargo:rerun-if-changed={source}");
|
||||
for byte in std::fs::read(source).expect("a source file") {
|
||||
stamp = (stamp ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
}
|
||||
println!("cargo:rustc-env=ELY_PDF_BUILD={stamp:016x}");
|
||||
println!("cargo:rerun-if-env-changed=ELY_MUPDF_DIR");
|
||||
for lib in ["libmupdf.a", "libmupdf-third.a", "libmupdf-pkcs7.a"] {
|
||||
println!("cargo:rerun-if-changed={}", mupdf.join(lib).display());
|
||||
if !mupdf.join(lib).is_file() {
|
||||
panic!("no {lib} in {} (run scripts/build-mupdf.sh or set ELY_MUPDF_DIR)", mupdf.display());
|
||||
}
|
||||
}
|
||||
cc::Build::new()
|
||||
.file("shim/ely_mupdf.c")
|
||||
.file("shim/ely_pages.c")
|
||||
.file("shim/ely_insert.c")
|
||||
.file("shim/ely_text.c")
|
||||
.file("shim/ely_write.c")
|
||||
.file("shim/ely_place.c")
|
||||
.file("shim/ely_annot.c")
|
||||
.file("shim/ely_form.c")
|
||||
.file("shim/ely_ocr.c")
|
||||
.file("shim/ely_outline.c")
|
||||
.file("shim/ely_meta.c")
|
||||
.file("shim/ely_redact.c")
|
||||
.file("shim/ely_alt.c")
|
||||
.file("shim/ely_clean.c")
|
||||
.file("shim/ely_secure.c")
|
||||
.file("shim/ely_sign.c")
|
||||
.file("shim/ely_output.c")
|
||||
.include(root.join("reference/mupdf/include"))
|
||||
.include(openssl("includedir"))
|
||||
.warnings(true)
|
||||
.cargo_metadata(false)
|
||||
.compile("ely_mupdf");
|
||||
let out = PathBuf::from(std::env::var("OUT_DIR").expect("set by cargo"));
|
||||
let bin = "ely-pdf-worker";
|
||||
println!("cargo:rustc-link-arg-bin={bin}={}", out.join("libely_mupdf.a").display());
|
||||
println!("cargo:rustc-link-arg-bin={bin}={}", mupdf.join("libmupdf-pkcs7.a").display());
|
||||
println!("cargo:rustc-link-arg-bin={bin}={}", mupdf.join("libmupdf.a").display());
|
||||
println!("cargo:rustc-link-arg-bin={bin}={}", mupdf.join("libmupdf-third.a").display());
|
||||
// Signing and checking need OpenSSL, linked in whole.
|
||||
println!("cargo:rustc-link-arg-bin={bin}={}", PathBuf::from(openssl("libdir")).join("libcrypto.a").display());
|
||||
println!("cargo:rustc-link-arg-bin={bin}=-lm");
|
||||
// Tesseract inside MuPDF is C++.
|
||||
let cxx = if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { "-lc++" } else { "-lstdc++" };
|
||||
println!("cargo:rustc-link-arg-bin={bin}={cxx}");
|
||||
}
|
||||
|
||||
/// An OpenSSL directory, from pkg-config.
|
||||
fn openssl(variable: &str) -> String {
|
||||
let out = std::process::Command::new("pkg-config")
|
||||
.args([&format!("--variable={variable}"), "libcrypto"])
|
||||
.output()
|
||||
.expect("pkg-config, to find OpenSSL");
|
||||
assert!(out.status.success(), "pkg-config knows no libcrypto");
|
||||
String::from_utf8(out.stdout).expect("a UTF-8 path").trim().to_string()
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Great Vibes Pro Project Authors (https://github.com/googlefonts/great-vibes)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Alternate text dropped from redacted pages (PDF-09). */
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
typedef void (bdc_fn)(fz_context *ctx, pdf_processor *proc, const char *tag, pdf_obj *raw, pdf_obj *cooked);
|
||||
|
||||
static void drop_said(fz_context *ctx, pdf_obj *dict)
|
||||
{
|
||||
if (!pdf_is_dict(ctx, dict))
|
||||
return;
|
||||
pdf_dict_del(ctx, dict, PDF_NAME(ActualText));
|
||||
pdf_dict_del(ctx, dict, PDF_NAME(Alt));
|
||||
pdf_dict_del(ctx, dict, PDF_NAME(E));
|
||||
}
|
||||
|
||||
static void quiet_bdc(fz_context *ctx, pdf_processor *proc, const char *tag, pdf_obj *raw, pdf_obj *cooked)
|
||||
{
|
||||
bdc_fn **out = fz_user_context(ctx);
|
||||
drop_said(ctx, raw);
|
||||
drop_said(ctx, cooked);
|
||||
(*out)(ctx, proc, tag, raw, cooked);
|
||||
}
|
||||
|
||||
/* Each output stream writes marked content through `quiet_bdc`. */
|
||||
static pdf_processor *quiet_factory(fz_context *ctx, pdf_document *doc, pdf_processor *chain, int struct_parents,
|
||||
fz_matrix transform, pdf_filter_options *options, void *sopts)
|
||||
{
|
||||
bdc_fn **out = fz_user_context(ctx);
|
||||
if (chain->op_BDC != quiet_bdc)
|
||||
{
|
||||
*out = chain->op_BDC;
|
||||
chain->op_BDC = quiet_bdc;
|
||||
}
|
||||
return pdf_new_sanitize_filter(ctx, doc, chain, struct_parents, transform, options, sopts);
|
||||
}
|
||||
|
||||
/* Strips elements on `page`, by own, inherited or kids' `Pg`. */
|
||||
static int quiet_elem(fz_context *ctx, pdf_obj *elem, int page, int inherited)
|
||||
{
|
||||
pdf_obj *kids;
|
||||
int pg, i, on = 0;
|
||||
if (!pdf_is_dict(ctx, elem) || pdf_mark_obj(ctx, elem))
|
||||
return 0;
|
||||
fz_try(ctx)
|
||||
{
|
||||
pg = pdf_dict_gets(ctx, elem, "Pg") ? pdf_to_num(ctx, pdf_dict_gets(ctx, elem, "Pg")) : inherited;
|
||||
on = pg == page;
|
||||
kids = pdf_dict_get(ctx, elem, PDF_NAME(K));
|
||||
if (pdf_is_array(ctx, kids))
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
on |= quiet_elem(ctx, pdf_array_get(ctx, kids, i), page, pg);
|
||||
else
|
||||
on |= quiet_elem(ctx, kids, page, pg);
|
||||
if (on)
|
||||
{
|
||||
drop_said(ctx, elem);
|
||||
pdf_dict_del(ctx, elem, PDF_NAME(T));
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_unmark_obj(ctx, elem);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return on;
|
||||
}
|
||||
|
||||
/* Drops what marked content says on `page`, forms too. */
|
||||
void ely_drop_alt(fz_context *ctx, pdf_document *pdf, pdf_page *page)
|
||||
{
|
||||
pdf_sanitize_filter_options sopts = { 0 };
|
||||
pdf_filter_factory list[2] = { { quiet_factory, NULL }, { NULL, NULL } };
|
||||
pdf_filter_options opts = { 0 };
|
||||
void *before = fz_user_context(ctx);
|
||||
bdc_fn *out = NULL;
|
||||
list[0].options = &sopts;
|
||||
opts.recurse = 1;
|
||||
opts.filters = list;
|
||||
fz_set_user_context(ctx, &out);
|
||||
fz_try(ctx)
|
||||
pdf_filter_page_contents(ctx, pdf, page, &opts);
|
||||
fz_always(ctx)
|
||||
fz_set_user_context(ctx, before);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
quiet_elem(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/StructTreeRoot"), pdf_to_num(ctx, page->obj), 0);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/* Annotations: list, add, change, delete (PDF-05). */
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
static const char *stamps[] = { "Approved", "AsIs", "Confidential", "Departmental", "Draft", "Experimental",
|
||||
"Expired", "Final", "ForComment", "ForPublicRelease", "NotApproved", "NotForPublicRelease", "Sold",
|
||||
"TopSecret", NULL };
|
||||
|
||||
void ely_json_string(fz_context *ctx, fz_output *out, const char *s)
|
||||
{
|
||||
fz_write_byte(ctx, out, '"');
|
||||
for (; *s; s++)
|
||||
{
|
||||
unsigned char c = (unsigned char)*s;
|
||||
if (c == '"' || c == '\\')
|
||||
fz_write_printf(ctx, out, "\\%c", c);
|
||||
else if (c < 32)
|
||||
fz_write_printf(ctx, out, "\\u%04x", c);
|
||||
else
|
||||
fz_write_byte(ctx, out, c);
|
||||
}
|
||||
fz_write_byte(ctx, out, '"');
|
||||
}
|
||||
|
||||
fz_json *ely_member(fz_context *ctx, fz_json *obj, const char *key, int type)
|
||||
{
|
||||
fz_json *value = fz_json_object_get(ctx, obj, key);
|
||||
if (value && value->type != type)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad %s", key);
|
||||
return value;
|
||||
}
|
||||
|
||||
float ely_number(fz_context *ctx, fz_json *array, int at)
|
||||
{
|
||||
fz_json *value = fz_json_array_get(ctx, array, at);
|
||||
if (!value || value->type != FZ_JSON_NUMBER)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad number list");
|
||||
return (float)value->u.number;
|
||||
}
|
||||
|
||||
/* Four numbers, at least `least` wide and high. */
|
||||
fz_rect ely_rect(fz_context *ctx, fz_json *array, float least)
|
||||
{
|
||||
fz_rect r;
|
||||
if (!array || array->type != FZ_JSON_ARRAY || fz_json_array_length(ctx, array) != 4)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad rectangle");
|
||||
r = fz_make_rect(ely_number(ctx, array, 0), ely_number(ctx, array, 1), ely_number(ctx, array, 2),
|
||||
ely_number(ctx, array, 3));
|
||||
if (r.x1 - r.x0 < least || r.y1 - r.y0 < least)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a rectangle too small");
|
||||
return r;
|
||||
}
|
||||
|
||||
static enum pdf_annot_type kind_of(fz_context *ctx, const char *kind)
|
||||
{
|
||||
if (!strcmp(kind, "highlight")) return PDF_ANNOT_HIGHLIGHT;
|
||||
if (!strcmp(kind, "underline")) return PDF_ANNOT_UNDERLINE;
|
||||
if (!strcmp(kind, "strikeout")) return PDF_ANNOT_STRIKE_OUT;
|
||||
if (!strcmp(kind, "note")) return PDF_ANNOT_TEXT;
|
||||
if (!strcmp(kind, "square")) return PDF_ANNOT_SQUARE;
|
||||
if (!strcmp(kind, "circle")) return PDF_ANNOT_CIRCLE;
|
||||
if (!strcmp(kind, "line")) return PDF_ANNOT_LINE;
|
||||
if (!strcmp(kind, "ink")) return PDF_ANNOT_INK;
|
||||
if (!strcmp(kind, "stamp")) return PDF_ANNOT_STAMP;
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no annotation kind %s", kind);
|
||||
}
|
||||
|
||||
static int rgb_of(fz_context *ctx, pdf_annot *annot)
|
||||
{
|
||||
float c[4], k;
|
||||
int n;
|
||||
pdf_annot_color(ctx, annot, &n, c);
|
||||
if (n == 1)
|
||||
c[1] = c[2] = c[0];
|
||||
else if (n == 4)
|
||||
{
|
||||
k = 1 - c[3];
|
||||
c[0] = (1 - c[0]) * k, c[1] = (1 - c[1]) * k, c[2] = (1 - c[2]) * k;
|
||||
}
|
||||
else if (n != 3)
|
||||
return -1;
|
||||
return ((int)(c[0] * 255 + 0.5f) << 16) | ((int)(c[1] * 255 + 0.5f) << 8) | (int)(c[2] * 255 + 0.5f);
|
||||
}
|
||||
|
||||
static int color_of(fz_context *ctx, fz_json *spec)
|
||||
{
|
||||
fz_json *color = ely_member(ctx, spec, "color", FZ_JSON_NUMBER);
|
||||
if (!color)
|
||||
return -1;
|
||||
if (color->u.number < 0 || color->u.number > 0xffffff)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad color");
|
||||
return (int)color->u.number;
|
||||
}
|
||||
|
||||
static const char *text_of(fz_context *ctx, fz_json *spec, const char *key)
|
||||
{
|
||||
fz_json *text = ely_member(ctx, spec, key, FZ_JSON_STRING);
|
||||
return text ? text->u.string : NULL;
|
||||
}
|
||||
|
||||
static int is_stamp(const char *name)
|
||||
{
|
||||
int i;
|
||||
for (i = 0; name && stamps[i]; i++)
|
||||
if (!strcmp(stamps[i], name))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Throws unless `spec` makes a whole annotation. */
|
||||
static enum pdf_annot_type check(fz_context *ctx, fz_json *spec, int pages)
|
||||
{
|
||||
fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER);
|
||||
fz_json *kind = ely_member(ctx, spec, "kind", FZ_JSON_STRING);
|
||||
fz_json *list;
|
||||
enum pdf_annot_type type;
|
||||
int i, n;
|
||||
if (!page || page->u.number < 0 || page->u.number >= pages || !kind)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "an annotation with no page or kind");
|
||||
type = kind_of(ctx, kind->u.string);
|
||||
color_of(ctx, spec);
|
||||
text_of(ctx, spec, "contents");
|
||||
text_of(ctx, spec, "author");
|
||||
switch (type)
|
||||
{
|
||||
case PDF_ANNOT_HIGHLIGHT:
|
||||
case PDF_ANNOT_UNDERLINE:
|
||||
case PDF_ANNOT_STRIKE_OUT:
|
||||
list = ely_member(ctx, spec, "rects", FZ_JSON_ARRAY);
|
||||
n = list ? fz_json_array_length(ctx, list) : 0;
|
||||
if (n == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a mark over nothing");
|
||||
for (i = 0; i < n; i++)
|
||||
ely_rect(ctx, fz_json_array_get(ctx, list, i), 0.1f);
|
||||
break;
|
||||
case PDF_ANNOT_TEXT:
|
||||
list = ely_member(ctx, spec, "at", FZ_JSON_ARRAY);
|
||||
if (!list || fz_json_array_length(ctx, list) != 2)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a note with no place");
|
||||
ely_number(ctx, list, 0), ely_number(ctx, list, 1);
|
||||
break;
|
||||
case PDF_ANNOT_LINE:
|
||||
list = ely_member(ctx, spec, "line", FZ_JSON_ARRAY);
|
||||
if (!list || fz_json_array_length(ctx, list) != 4)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a line with no ends");
|
||||
if (fabsf(ely_number(ctx, list, 2) - ely_number(ctx, list, 0)) +
|
||||
fabsf(ely_number(ctx, list, 3) - ely_number(ctx, list, 1)) < 2)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a line too short");
|
||||
break;
|
||||
case PDF_ANNOT_INK:
|
||||
list = ely_member(ctx, spec, "strokes", FZ_JSON_ARRAY);
|
||||
n = list ? fz_json_array_length(ctx, list) : 0;
|
||||
if (n == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "ink with no strokes");
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
fz_json *stroke = fz_json_array_get(ctx, list, i);
|
||||
int k, m = stroke->type == FZ_JSON_ARRAY ? fz_json_array_length(ctx, stroke) : 0;
|
||||
if (m < 4 || m % 2)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad stroke");
|
||||
for (k = 0; k < m; k++)
|
||||
ely_number(ctx, stroke, k);
|
||||
}
|
||||
break;
|
||||
case PDF_ANNOT_STAMP:
|
||||
if (!is_stamp(text_of(ctx, spec, "icon")))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no such stamp");
|
||||
ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2);
|
||||
break;
|
||||
default:
|
||||
ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2);
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
static fz_point point_at(fz_context *ctx, fz_json *list, int at)
|
||||
{
|
||||
return fz_make_point(ely_number(ctx, list, at), ely_number(ctx, list, at + 1));
|
||||
}
|
||||
|
||||
/* Makes a checked `spec` inside the open step. */
|
||||
static void make(fz_context *ctx, pdf_document *pdf, fz_json *spec, enum pdf_annot_type type)
|
||||
{
|
||||
pdf_page *page = NULL;
|
||||
pdf_annot *annot = NULL;
|
||||
fz_point *points = NULL;
|
||||
fz_var(page);
|
||||
fz_var(annot);
|
||||
fz_var(points);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *list;
|
||||
int i, k, n, color = color_of(ctx, spec);
|
||||
page = pdf_load_page(ctx, pdf, (int)ely_member(ctx, spec, "page", FZ_JSON_NUMBER)->u.number);
|
||||
annot = pdf_create_annot(ctx, page, type);
|
||||
switch (type)
|
||||
{
|
||||
case PDF_ANNOT_HIGHLIGHT:
|
||||
case PDF_ANNOT_UNDERLINE:
|
||||
case PDF_ANNOT_STRIKE_OUT:
|
||||
list = ely_member(ctx, spec, "rects", FZ_JSON_ARRAY);
|
||||
n = fz_json_array_length(ctx, list);
|
||||
for (i = 0; i < n; i++)
|
||||
pdf_add_annot_quad_point(ctx, annot, fz_quad_from_rect(ely_rect(ctx, fz_json_array_get(ctx, list, i), 0.1f)));
|
||||
break;
|
||||
case PDF_ANNOT_TEXT:
|
||||
{
|
||||
fz_point at = point_at(ctx, ely_member(ctx, spec, "at", FZ_JSON_ARRAY), 0);
|
||||
pdf_set_annot_rect(ctx, annot, fz_make_rect(at.x, at.y, at.x + 16, at.y + 16));
|
||||
pdf_set_annot_popup(ctx, annot, fz_make_rect(at.x + 20, at.y, at.x + 220, at.y + 100));
|
||||
break;
|
||||
}
|
||||
case PDF_ANNOT_LINE:
|
||||
list = ely_member(ctx, spec, "line", FZ_JSON_ARRAY);
|
||||
pdf_set_annot_line(ctx, annot, point_at(ctx, list, 0), point_at(ctx, list, 2));
|
||||
break;
|
||||
case PDF_ANNOT_INK:
|
||||
list = ely_member(ctx, spec, "strokes", FZ_JSON_ARRAY);
|
||||
n = fz_json_array_length(ctx, list);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
fz_json *stroke = fz_json_array_get(ctx, list, i);
|
||||
int m = fz_json_array_length(ctx, stroke) / 2;
|
||||
fz_free(ctx, points);
|
||||
points = NULL;
|
||||
points = fz_malloc_array(ctx, m, fz_point);
|
||||
for (k = 0; k < m; k++)
|
||||
points[k] = point_at(ctx, stroke, k * 2);
|
||||
pdf_add_annot_ink_list(ctx, annot, m, points);
|
||||
}
|
||||
break;
|
||||
case PDF_ANNOT_STAMP:
|
||||
pdf_set_annot_icon_name(ctx, annot, text_of(ctx, spec, "icon"));
|
||||
pdf_set_annot_rect(ctx, annot, ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2));
|
||||
break;
|
||||
default:
|
||||
pdf_set_annot_rect(ctx, annot, ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2));
|
||||
}
|
||||
if (color >= 0)
|
||||
{
|
||||
float rgb[3] = { (color >> 16) / 255.0f, ((color >> 8) & 255) / 255.0f, (color & 255) / 255.0f };
|
||||
pdf_set_annot_color(ctx, annot, 3, rgb);
|
||||
}
|
||||
if (text_of(ctx, spec, "contents"))
|
||||
pdf_set_annot_contents(ctx, annot, text_of(ctx, spec, "contents"));
|
||||
if (text_of(ctx, spec, "author"))
|
||||
pdf_set_annot_author(ctx, annot, text_of(ctx, spec, "author"));
|
||||
pdf_update_annot(ctx, annot);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, points);
|
||||
pdf_drop_annot(ctx, annot);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* A JSON list of annotations, added in one step. */
|
||||
int ely_annotate(fz_context *ctx, fz_document *doc, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
enum pdf_annot_type *types = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(pool);
|
||||
fz_var(types);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *specs;
|
||||
int i, n, pages;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
pages = pdf_count_pages(ctx, pdf);
|
||||
pool = fz_new_pool(ctx);
|
||||
specs = fz_parse_json(ctx, pool, json);
|
||||
n = specs->type == FZ_JSON_ARRAY ? fz_json_array_length(ctx, specs) : 0;
|
||||
if (n == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no annotations to add");
|
||||
types = fz_malloc_array(ctx, n, enum pdf_annot_type);
|
||||
for (i = 0; i < n; i++)
|
||||
types[i] = check(ctx, fz_json_array_get(ctx, specs, i), pages);
|
||||
pdf_begin_operation(ctx, pdf, "Annotate");
|
||||
begun = 1;
|
||||
for (i = 0; i < n; i++)
|
||||
make(ctx, pdf, fz_json_array_get(ctx, specs, i), types[i]);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, types);
|
||||
fz_drop_pool(ctx, pool);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The annotation or widget object `id` on `page`. */
|
||||
pdf_annot *ely_find_annot(fz_context *ctx, pdf_page *page, int id, int widget)
|
||||
{
|
||||
pdf_annot *annot = widget ? pdf_first_widget(ctx, page) : pdf_first_annot(ctx, page);
|
||||
for (; annot; annot = widget ? pdf_next_widget(ctx, annot) : pdf_next_annot(ctx, annot))
|
||||
if (pdf_to_num(ctx, pdf_annot_obj(ctx, annot)) == id)
|
||||
return annot;
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no %s %d on the page", widget ? "field" : "annotation", id);
|
||||
}
|
||||
|
||||
/* Sets an annotation's colour or contents, in one step. */
|
||||
int ely_change_annot(fz_context *ctx, fz_document *doc, int number, int id, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(pool);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *annot;
|
||||
fz_json *spec;
|
||||
const char *contents;
|
||||
int color;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
annot = ely_find_annot(ctx, page, id, 0);
|
||||
pool = fz_new_pool(ctx);
|
||||
spec = fz_parse_json(ctx, pool, json);
|
||||
color = color_of(ctx, spec);
|
||||
contents = text_of(ctx, spec, "contents");
|
||||
if (color < 0 && !contents)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no change");
|
||||
pdf_begin_operation(ctx, pdf, "Change an annotation");
|
||||
begun = 1;
|
||||
if (color >= 0)
|
||||
{
|
||||
float rgb[3] = { (color >> 16) / 255.0f, ((color >> 8) & 255) / 255.0f, (color & 255) / 255.0f };
|
||||
pdf_set_annot_color(ctx, annot, 3, rgb);
|
||||
}
|
||||
if (contents)
|
||||
pdf_set_annot_contents(ctx, annot, contents);
|
||||
pdf_update_annot(ctx, annot);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_pool(ctx, pool);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Unlinks `field`; an emptied parent goes too. */
|
||||
void ely_unlink_field(fz_context *ctx, pdf_document *pdf, pdf_obj *field)
|
||||
{
|
||||
pdf_obj *parent = pdf_dict_get(ctx, field, PDF_NAME(Parent));
|
||||
pdf_obj *kids = parent ? pdf_dict_get(ctx, parent, PDF_NAME(Kids)) : pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields");
|
||||
int at = pdf_array_find(ctx, kids, field);
|
||||
if (at >= 0)
|
||||
pdf_array_delete(ctx, kids, at);
|
||||
if (parent && pdf_array_len(ctx, kids) == 0)
|
||||
ely_unlink_field(ctx, pdf, parent);
|
||||
}
|
||||
|
||||
/* Deletes annotation or field `id`, in one step. */
|
||||
int ely_delete_annot(fz_context *ctx, fz_document *doc, int number, int id, int widget)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
pdf_obj *field = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(field);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *annot;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
annot = ely_find_annot(ctx, page, id, widget);
|
||||
field = pdf_keep_obj(ctx, pdf_annot_obj(ctx, annot));
|
||||
pdf_begin_operation(ctx, pdf, widget ? "Delete a field" : "Delete an annotation");
|
||||
begun = 1;
|
||||
pdf_delete_annot(ctx, page, annot);
|
||||
if (widget)
|
||||
ely_unlink_field(ctx, pdf, field);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, field);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void write_annot(fz_context *ctx, fz_output *out, pdf_annot *annot)
|
||||
{
|
||||
fz_rect r = pdf_bound_annot(ctx, annot);
|
||||
fz_write_printf(ctx, out, "{\"id\":%d,\"kind\":", pdf_to_num(ctx, pdf_annot_obj(ctx, annot)));
|
||||
ely_json_string(ctx, out, pdf_string_from_annot_type(ctx, pdf_annot_type(ctx, annot)));
|
||||
fz_write_printf(ctx, out, ",\"rect\":[%g,%g,%g,%g],\"color\":%d,\"contents\":", r.x0, r.y0, r.x1, r.y1,
|
||||
rgb_of(ctx, annot));
|
||||
ely_json_string(ctx, out, pdf_annot_contents(ctx, annot));
|
||||
fz_write_string(ctx, out, ",\"author\":");
|
||||
ely_json_string(ctx, out, pdf_annot_has_author(ctx, annot) ? pdf_annot_author(ctx, annot) : "");
|
||||
fz_write_string(ctx, out, ",\"icon\":");
|
||||
ely_json_string(ctx, out, pdf_annot_has_icon_name(ctx, annot) ? pdf_annot_icon_name(ctx, annot) : "");
|
||||
fz_write_byte(ctx, out, '}');
|
||||
}
|
||||
|
||||
/* JSON of a page's annotations and form fields. */
|
||||
int ely_annots(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len)
|
||||
{
|
||||
pdf_page *page = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(page);
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *annot;
|
||||
page = pdf_load_page(ctx, ely_pdf_of(ctx, doc), number);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_string(ctx, out, "{\"annots\":[");
|
||||
for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot))
|
||||
{
|
||||
if (annot != pdf_first_annot(ctx, page))
|
||||
fz_write_byte(ctx, out, ',');
|
||||
write_annot(ctx, out, annot);
|
||||
}
|
||||
fz_write_string(ctx, out, "],\"fields\":[");
|
||||
ely_write_fields(ctx, out, page);
|
||||
fz_write_string(ctx, out, "]}");
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/* Cleaning into a new file, counted before and after (PDF-10). */
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
enum { CLEAN_META = 1, CLEAN_ANNOTS = 2, CLEAN_FILES = 4, CLEAN_HIDDEN = 8, CLEAN_ALL = 15 };
|
||||
|
||||
/* What cleaning reaches, counted. */
|
||||
typedef struct
|
||||
{
|
||||
int meta, annots, files, hidden_pages, scripts, hidden_annots;
|
||||
} reach;
|
||||
|
||||
/* The filter running a page, and what it drops. */
|
||||
typedef struct
|
||||
{
|
||||
pdf_processor *top;
|
||||
int dropped;
|
||||
} hiding;
|
||||
|
||||
static pdf_processor *hide_factory(fz_context *ctx, pdf_document *doc, pdf_processor *chain, int struct_parents,
|
||||
fz_matrix transform, pdf_filter_options *options, void *sopts)
|
||||
{
|
||||
hiding *h = ((pdf_sanitize_filter_options *)sopts)->opaque;
|
||||
h->top = pdf_new_sanitize_filter(ctx, doc, chain, struct_parents, transform, options, sopts);
|
||||
/* Layers count as hidden only for a viewer's usage. */
|
||||
h->top->usage = "View";
|
||||
return h->top;
|
||||
}
|
||||
|
||||
/* Unseen text: modes 3 and 7, or zero opacity. */
|
||||
static int hide_text(fz_context *ctx, void *opaque, int *ucs, int len, fz_matrix trm, fz_matrix ctm, fz_rect bbox,
|
||||
int tr, float ca, float CA)
|
||||
{
|
||||
hiding *h = opaque;
|
||||
int fills = tr == 0 || tr == 2 || tr == 4 || tr == 6, strokes = tr == 1 || tr == 2 || tr == 5 || tr == 6;
|
||||
int drop = h->top->hidden > 0 || tr == 3 || tr == 7 || ((!fills || ca == 0) && (!strokes || CA == 0));
|
||||
(void)ctx, (void)ucs, (void)len, (void)trm, (void)ctm, (void)bbox;
|
||||
h->dropped += drop;
|
||||
return drop;
|
||||
}
|
||||
|
||||
/* Drawing inside a layer that is off. */
|
||||
static int hide_cull(fz_context *ctx, void *opaque, fz_rect bbox, fz_cull_type type)
|
||||
{
|
||||
hiding *h = opaque;
|
||||
(void)ctx, (void)bbox, (void)type;
|
||||
h->dropped += h->top->hidden > 0;
|
||||
return h->top->hidden > 0;
|
||||
}
|
||||
|
||||
/* Drops hidden text and hidden layers from `page`; how many. */
|
||||
static int drop_hidden(fz_context *ctx, pdf_document *pdf, pdf_page *page)
|
||||
{
|
||||
hiding h = { NULL, 0 };
|
||||
pdf_sanitize_filter_options sopts = { 0 };
|
||||
pdf_filter_factory list[2] = { { hide_factory, NULL }, { NULL, NULL } };
|
||||
pdf_filter_options opts = { 0 };
|
||||
sopts.opaque = &h;
|
||||
sopts.text_filter = hide_text;
|
||||
sopts.culler = hide_cull;
|
||||
list[0].options = &sopts;
|
||||
opts.recurse = 1;
|
||||
opts.filters = list;
|
||||
pdf_filter_page_contents(ctx, pdf, page, &opts);
|
||||
if (h.dropped)
|
||||
ely_drop_alt(ctx, pdf, page);
|
||||
return h.dropped;
|
||||
}
|
||||
|
||||
static int is_script(fz_context *ctx, pdf_obj *action)
|
||||
{
|
||||
return pdf_is_dict(ctx, action) &&
|
||||
(pdf_name_eq(ctx, pdf_dict_get(ctx, action, PDF_NAME(S)), PDF_NAME(JavaScript)) || pdf_dict_get(ctx, action, PDF_NAME(JS)));
|
||||
}
|
||||
|
||||
/* `action` without its scripts, their Next chains kept. */
|
||||
static pdf_obj *spliced(fz_context *ctx, pdf_document *pdf, pdf_obj *action, int *n)
|
||||
{
|
||||
pdf_obj *out = NULL, *each = NULL;
|
||||
int i, k;
|
||||
fz_var(each);
|
||||
if (is_script(ctx, action))
|
||||
{
|
||||
if (pdf_mark_obj(ctx, action))
|
||||
return NULL;
|
||||
(*n)++;
|
||||
fz_try(ctx)
|
||||
out = spliced(ctx, pdf, pdf_dict_get(ctx, action, PDF_NAME(Next)), n);
|
||||
fz_always(ctx)
|
||||
pdf_unmark_obj(ctx, action);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return out;
|
||||
}
|
||||
if (!pdf_is_array(ctx, action))
|
||||
return pdf_keep_obj(ctx, action);
|
||||
out = pdf_new_array(ctx, pdf, pdf_array_len(ctx, action));
|
||||
fz_try(ctx)
|
||||
for (i = 0; i < pdf_array_len(ctx, action); i++)
|
||||
{
|
||||
each = spliced(ctx, pdf, pdf_array_get(ctx, action, i), n);
|
||||
if (pdf_is_array(ctx, each))
|
||||
for (k = 0; k < pdf_array_len(ctx, each); k++)
|
||||
pdf_array_push(ctx, out, pdf_array_get(ctx, each, k));
|
||||
else if (each)
|
||||
pdf_array_push(ctx, out, each);
|
||||
pdf_drop_obj(ctx, each);
|
||||
each = NULL;
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_drop_obj(ctx, each);
|
||||
fz_catch(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, out);
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Splices scripts out of `dict[key]`; how many. */
|
||||
static int splice_key(fz_context *ctx, pdf_document *pdf, pdf_obj *dict, const char *key)
|
||||
{
|
||||
pdf_obj *now = pdf_dict_gets(ctx, dict, key), *kept;
|
||||
int n = 0;
|
||||
if (!now)
|
||||
return 0;
|
||||
kept = spliced(ctx, pdf, now, &n);
|
||||
if (n == 0)
|
||||
pdf_drop_obj(ctx, kept);
|
||||
else if (kept && !(pdf_is_array(ctx, kept) && pdf_array_len(ctx, kept) == 0))
|
||||
pdf_dict_puts_drop(ctx, dict, key, kept);
|
||||
else
|
||||
{
|
||||
pdf_drop_obj(ctx, kept);
|
||||
pdf_dict_dels(ctx, dict, key);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static int drop_key(fz_context *ctx, pdf_obj *dict, const char *key)
|
||||
{
|
||||
int had = pdf_dict_gets(ctx, dict, key) != NULL;
|
||||
pdf_dict_dels(ctx, dict, key);
|
||||
return had;
|
||||
}
|
||||
|
||||
/* Cleans `obj` and the direct values inside it. */
|
||||
static void clean_obj(fz_context *ctx, pdf_document *pdf, pdf_obj *obj, int mask, reach *r)
|
||||
{
|
||||
pdf_obj *aa;
|
||||
int i;
|
||||
if (pdf_is_dict(ctx, obj))
|
||||
{
|
||||
if (mask & CLEAN_META)
|
||||
r->meta += drop_key(ctx, obj, "Metadata") + drop_key(ctx, obj, "PieceInfo");
|
||||
if (mask & CLEAN_FILES)
|
||||
{
|
||||
r->files += drop_key(ctx, obj, "EF");
|
||||
drop_key(ctx, obj, "RF");
|
||||
drop_key(ctx, obj, "AF");
|
||||
}
|
||||
if (mask & CLEAN_HIDDEN)
|
||||
{
|
||||
r->scripts += splice_key(ctx, pdf, obj, "A") + splice_key(ctx, pdf, obj, "OpenAction") +
|
||||
splice_key(ctx, pdf, obj, "Next");
|
||||
aa = pdf_dict_get(ctx, obj, PDF_NAME(AA));
|
||||
for (i = pdf_dict_len(ctx, aa) - 1; i >= 0; i--)
|
||||
r->scripts += splice_key(ctx, pdf, aa, pdf_to_name(ctx, pdf_dict_get_key(ctx, aa, i)));
|
||||
if (pdf_is_dict(ctx, aa) && pdf_dict_len(ctx, aa) == 0)
|
||||
pdf_dict_del(ctx, obj, PDF_NAME(AA));
|
||||
}
|
||||
for (i = 0; i < pdf_dict_len(ctx, obj); i++)
|
||||
if (!pdf_is_indirect(ctx, pdf_dict_get_val(ctx, obj, i)))
|
||||
clean_obj(ctx, pdf, pdf_dict_get_val(ctx, obj, i), mask, r);
|
||||
}
|
||||
else if (pdf_is_array(ctx, obj))
|
||||
for (i = 0; i < pdf_array_len(ctx, obj); i++)
|
||||
if (!pdf_is_indirect(ctx, pdf_array_get(ctx, obj, i)))
|
||||
clean_obj(ctx, pdf, pdf_array_get(ctx, obj, i), mask, r);
|
||||
}
|
||||
|
||||
/* Every object: metadata, embedded bytes, scripts. */
|
||||
static void clean_all(fz_context *ctx, pdf_document *pdf, int mask, reach *r)
|
||||
{
|
||||
int i;
|
||||
for (i = 1; i < pdf_xref_len(ctx, pdf); i++)
|
||||
{
|
||||
pdf_obj *obj = pdf_new_indirect(ctx, pdf, i, 0);
|
||||
fz_try(ctx)
|
||||
clean_obj(ctx, pdf, pdf_resolve_indirect(ctx, obj), mask, r);
|
||||
fz_always(ctx)
|
||||
pdf_drop_obj(ctx, obj);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
static int unseen(fz_context *ctx, pdf_obj *annot)
|
||||
{
|
||||
return (pdf_dict_get_int(ctx, annot, PDF_NAME(F)) & (PDF_ANNOT_IS_HIDDEN | PDF_ANNOT_IS_INVISIBLE)) != 0;
|
||||
}
|
||||
|
||||
/* Hidden fields go with their values. */
|
||||
static int drop_hidden_widgets(fz_context *ctx, pdf_page *page)
|
||||
{
|
||||
pdf_annot *widget;
|
||||
int n = 0;
|
||||
restart:
|
||||
for (widget = pdf_first_widget(ctx, page); widget; widget = pdf_next_widget(ctx, widget))
|
||||
if (unseen(ctx, pdf_annot_obj(ctx, widget)))
|
||||
{
|
||||
pdf_obj *field = pdf_keep_obj(ctx, pdf_annot_obj(ctx, widget));
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_delete_annot(ctx, page, widget);
|
||||
ely_unlink_field(ctx, page->doc, field);
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_drop_obj(ctx, field);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
n++;
|
||||
goto restart;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Links are not in MuPDF's annotation list. */
|
||||
static int drop_hidden_links(fz_context *ctx, pdf_page *page)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, page->obj, PDF_NAME(Annots));
|
||||
int i, n = 0;
|
||||
for (i = pdf_array_len(ctx, annots) - 1; i >= 0; i--)
|
||||
{
|
||||
pdf_obj *annot = pdf_array_get(ctx, annots, i);
|
||||
if (pdf_name_eq(ctx, pdf_dict_get(ctx, annot, PDF_NAME(Subtype)), PDF_NAME(Link)) && unseen(ctx, annot))
|
||||
{
|
||||
pdf_array_delete(ctx, annots, i);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static void sweep_page(fz_context *ctx, pdf_document *pdf, pdf_page *page, int mask, reach *r)
|
||||
{
|
||||
pdf_annot *annot;
|
||||
if (mask & CLEAN_HIDDEN)
|
||||
r->hidden_pages += drop_hidden(ctx, pdf, page) > 0;
|
||||
restart:
|
||||
for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot))
|
||||
{
|
||||
int file = pdf_annot_type(ctx, annot) == PDF_ANNOT_FILE_ATTACHMENT;
|
||||
int hidden = (pdf_annot_flags(ctx, annot) & (PDF_ANNOT_IS_HIDDEN | PDF_ANNOT_IS_INVISIBLE)) != 0;
|
||||
if ((file && (mask & CLEAN_FILES)) || (!file && (mask & CLEAN_ANNOTS)) || (hidden && (mask & CLEAN_HIDDEN)))
|
||||
{
|
||||
r->hidden_annots += hidden && !file;
|
||||
r->annots += !file && !hidden;
|
||||
pdf_delete_annot(ctx, page, annot);
|
||||
goto restart;
|
||||
}
|
||||
}
|
||||
if (mask & CLEAN_HIDDEN)
|
||||
r->hidden_annots += drop_hidden_widgets(ctx, page) + drop_hidden_links(ctx, page);
|
||||
}
|
||||
|
||||
/* Drops what `mask` names from `doc`, counting what was there. */
|
||||
static reach sweep(fz_context *ctx, fz_document *doc, int mask)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
pdf_obj *trailer = pdf_trailer(ctx, pdf), *root = pdf_dict_get(ctx, trailer, PDF_NAME(Root));
|
||||
pdf_obj *names = pdf_dict_get(ctx, root, PDF_NAME(Names)), *tree;
|
||||
pdf_page *page = NULL;
|
||||
reach r = { 0 };
|
||||
int i;
|
||||
fz_var(page);
|
||||
if (mask & CLEAN_META)
|
||||
{
|
||||
r.meta = pdf_dict_len(ctx, pdf_dict_get(ctx, trailer, PDF_NAME(Info)));
|
||||
pdf_dict_del(ctx, trailer, PDF_NAME(Info));
|
||||
}
|
||||
if ((mask & CLEAN_FILES) && names)
|
||||
pdf_dict_del(ctx, names, PDF_NAME(EmbeddedFiles));
|
||||
if (mask & CLEAN_HIDDEN)
|
||||
{
|
||||
tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(JavaScript));
|
||||
r.scripts = pdf_dict_len(ctx, tree);
|
||||
pdf_drop_obj(ctx, tree);
|
||||
if (names)
|
||||
pdf_dict_del(ctx, names, PDF_NAME(JavaScript));
|
||||
}
|
||||
clean_all(ctx, pdf, mask, &r);
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (i = 0; i < pdf_count_pages(ctx, pdf); i++)
|
||||
{
|
||||
page = pdf_load_page(ctx, pdf, i);
|
||||
sweep_page(ctx, pdf, page, mask, &r);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
page = NULL;
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return r;
|
||||
}
|
||||
|
||||
static void write_reach(fz_context *ctx, fz_buffer *buf, reach r)
|
||||
{
|
||||
fz_append_printf(ctx, buf, "{\"meta\":%d,\"annots\":%d,\"files\":%d,\"hidden\":%d,\"scripts\":%d,\"hidden_annots\":%d}",
|
||||
r.meta, r.annots, r.files, r.hidden_pages, r.scripts, r.hidden_annots);
|
||||
}
|
||||
|
||||
/* Counts by sweeping a throwaway copy of `pdf`. */
|
||||
static reach count_copy(fz_context *ctx, pdf_document *pdf, const char *raw, const char *password)
|
||||
{
|
||||
fz_document *copy = NULL;
|
||||
reach r;
|
||||
fz_var(copy);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_save_snapshot(ctx, pdf, raw);
|
||||
copy = fz_open_document(ctx, raw);
|
||||
if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the copy refused its password");
|
||||
r = sweep(ctx, copy, CLEAN_ALL);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_document(ctx, copy);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* What cleaning would reach, as JSON. */
|
||||
int ely_clean_scan(fz_context *ctx, fz_document *doc, const char *raw, const char *password, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
buf = fz_new_buffer(ctx, 128);
|
||||
write_reach(ctx, buf, count_copy(ctx, ely_pdf_of(ctx, doc), raw, password));
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void apply_clean(fz_context *ctx, fz_document *copy, void *arg)
|
||||
{
|
||||
sweep(ctx, copy, *(int *)arg);
|
||||
}
|
||||
|
||||
/* Cleans `mask` into `out`, then counts what is left. */
|
||||
int ely_clean(fz_context *ctx, fz_document *doc, int mask, const char *raw, const char *out, const char *password,
|
||||
unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_document *written = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(written);
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
reach left;
|
||||
if (mask <= 0 || mask > CLEAN_ALL)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "nothing chosen to clean");
|
||||
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, NULL, apply_clean, &mask);
|
||||
written = fz_open_document(ctx, out);
|
||||
if (fz_needs_password(ctx, written) && !fz_authenticate_password(ctx, written, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the written file refused its password");
|
||||
left = count_copy(ctx, ely_pdf_of(ctx, written), raw, password);
|
||||
buf = fz_new_buffer(ctx, 128);
|
||||
write_reach(ctx, buf, left);
|
||||
if (((mask & CLEAN_META) && left.meta) || ((mask & CLEAN_ANNOTS) && left.annots) || ((mask & CLEAN_FILES) && left.files) ||
|
||||
((mask & CLEAN_HIDDEN) && (left.hidden_pages || left.scripts || left.hidden_annots)))
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "the written file still holds what was cleaned");
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_document(ctx, written);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/* Form fields: list, add, fill, change (PDF-06). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
static const char *kind_name(enum pdf_widget_type type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case PDF_WIDGET_TYPE_TEXT: return "text";
|
||||
case PDF_WIDGET_TYPE_CHECKBOX: return "checkbox";
|
||||
case PDF_WIDGET_TYPE_RADIOBUTTON: return "radio";
|
||||
case PDF_WIDGET_TYPE_COMBOBOX: return "choice";
|
||||
case PDF_WIDGET_TYPE_LISTBOX: return "list";
|
||||
case PDF_WIDGET_TYPE_SIGNATURE: return "signature";
|
||||
default: return "button";
|
||||
}
|
||||
}
|
||||
|
||||
/* The field that names `obj`. */
|
||||
static pdf_obj *head_of(fz_context *ctx, pdf_obj *obj)
|
||||
{
|
||||
while (!pdf_dict_get(ctx, obj, PDF_NAME(T)) && pdf_dict_get(ctx, obj, PDF_NAME(Parent)))
|
||||
obj = pdf_dict_get(ctx, obj, PDF_NAME(Parent));
|
||||
return obj;
|
||||
}
|
||||
|
||||
static pdf_obj *fields_of(fz_context *ctx, pdf_document *pdf)
|
||||
{
|
||||
pdf_obj *root = pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root));
|
||||
pdf_obj *form = pdf_dict_get(ctx, root, PDF_NAME(AcroForm));
|
||||
if (!form)
|
||||
form = pdf_dict_put_dict(ctx, root, PDF_NAME(AcroForm), 2);
|
||||
if (!pdf_is_array(ctx, pdf_dict_get(ctx, form, PDF_NAME(Fields))))
|
||||
pdf_dict_put_array(ctx, form, PDF_NAME(Fields), 4);
|
||||
return pdf_dict_get(ctx, form, PDF_NAME(Fields));
|
||||
}
|
||||
|
||||
/* A value as text; names and streams too. */
|
||||
static void write_value(fz_context *ctx, fz_output *out, pdf_obj *v)
|
||||
{
|
||||
char *text = NULL;
|
||||
fz_var(text);
|
||||
fz_try(ctx)
|
||||
{
|
||||
if (pdf_is_name(ctx, v))
|
||||
ely_json_string(ctx, out, pdf_to_name(ctx, v));
|
||||
else if (pdf_is_stream(ctx, v))
|
||||
{
|
||||
text = pdf_new_utf8_from_pdf_stream_obj(ctx, v);
|
||||
ely_json_string(ctx, out, text);
|
||||
}
|
||||
else
|
||||
ely_json_string(ctx, out, pdf_to_text_string(ctx, v));
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, text);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
static void write_options(fz_context *ctx, fz_output *out, pdf_obj *obj, int export)
|
||||
{
|
||||
int i, n = pdf_choice_field_option_count(ctx, obj);
|
||||
fz_write_byte(ctx, out, '[');
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
if (i)
|
||||
fz_write_byte(ctx, out, ',');
|
||||
ely_json_string(ctx, out, pdf_choice_field_option(ctx, obj, export, i));
|
||||
}
|
||||
fz_write_byte(ctx, out, ']');
|
||||
}
|
||||
|
||||
void ely_write_fields(fz_context *ctx, fz_output *out, pdf_page *page)
|
||||
{
|
||||
pdf_annot *widget;
|
||||
char *name = NULL;
|
||||
fz_var(name);
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (widget = pdf_first_widget(ctx, page); widget; widget = pdf_next_widget(ctx, widget))
|
||||
{
|
||||
pdf_obj *obj = pdf_annot_obj(ctx, widget);
|
||||
enum pdf_widget_type type = pdf_widget_type(ctx, widget);
|
||||
fz_rect r = pdf_bound_widget(ctx, widget);
|
||||
int button = type == PDF_WIDGET_TYPE_CHECKBOX || type == PDF_WIDGET_TYPE_RADIOBUTTON;
|
||||
pdf_obj *as = pdf_dict_get(ctx, obj, PDF_NAME(AS));
|
||||
fz_free(ctx, name);
|
||||
name = NULL;
|
||||
name = pdf_load_field_name(ctx, obj);
|
||||
fz_write_printf(ctx, out, "%s{\"id\":%d,\"kind\":\"%s\",\"name\":", widget == pdf_first_widget(ctx, page) ? "" : ",",
|
||||
pdf_to_num(ctx, obj), kind_name(type));
|
||||
ely_json_string(ctx, out, name);
|
||||
fz_write_printf(ctx, out, ",\"rect\":[%g,%g,%g,%g],\"value\":", r.x0, r.y0, r.x1, r.y1);
|
||||
write_value(ctx, out, pdf_dict_get_inheritable(ctx, obj, PDF_NAME(V)));
|
||||
fz_write_string(ctx, out, ",\"on\":");
|
||||
ely_json_string(ctx, out, button ? pdf_to_name(ctx, pdf_button_field_on_state(ctx, obj)) : "");
|
||||
fz_write_printf(ctx, out, ",\"checked\":%s,\"readonly\":%s,\"options\":",
|
||||
button && pdf_is_name(ctx, as) && !pdf_name_eq(ctx, as, PDF_NAME(Off)) ? "true" : "false",
|
||||
pdf_widget_is_readonly(ctx, widget) ? "true" : "false");
|
||||
write_options(ctx, out, obj, 1);
|
||||
fz_write_string(ctx, out, ",\"labels\":");
|
||||
write_options(ctx, out, obj, 0);
|
||||
fz_write_byte(ctx, out, '}');
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, name);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Shows `state` on each button under `node`, else Off. */
|
||||
static void show_state(fz_context *ctx, pdf_obj *node, pdf_obj *state)
|
||||
{
|
||||
pdf_obj *kids = pdf_dict_get(ctx, node, PDF_NAME(Kids));
|
||||
int i;
|
||||
if (kids)
|
||||
{
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
show_state(ctx, pdf_array_get(ctx, kids, i), state);
|
||||
return;
|
||||
}
|
||||
pdf_dict_put(ctx, node, PDF_NAME(AS), pdf_dict_get(ctx, pdf_dict_getp(ctx, node, "AP/N"), state) ? state : PDF_NAME(Off));
|
||||
}
|
||||
|
||||
/* Fills field widget `id` with `value`, in one step. */
|
||||
int ely_fill(fz_context *ctx, fz_document *doc, int number, int id, const char *value)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
pdf_obj *state = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(state);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *widget;
|
||||
pdf_obj *obj;
|
||||
enum pdf_widget_type type;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
widget = ely_find_annot(ctx, page, id, 1);
|
||||
obj = pdf_annot_obj(ctx, widget);
|
||||
type = pdf_widget_type(ctx, widget);
|
||||
if (pdf_widget_is_readonly(ctx, widget))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the field is read only");
|
||||
if (type == PDF_WIDGET_TYPE_CHECKBOX || type == PDF_WIDGET_TYPE_RADIOBUTTON)
|
||||
{
|
||||
state = pdf_new_name(ctx, value);
|
||||
if (!pdf_name_eq(ctx, state, PDF_NAME(Off)) && !pdf_name_eq(ctx, state, pdf_button_field_on_state(ctx, obj)))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the button has no state %s", value);
|
||||
}
|
||||
else if (type != PDF_WIDGET_TYPE_TEXT && type != PDF_WIDGET_TYPE_COMBOBOX && type != PDF_WIDGET_TYPE_LISTBOX)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a %s field cannot be filled", kind_name(type));
|
||||
pdf_begin_operation(ctx, pdf, "Fill a field");
|
||||
begun = 1;
|
||||
if (state)
|
||||
{
|
||||
pdf_dict_put(ctx, head_of(ctx, obj), PDF_NAME(V), state);
|
||||
show_state(ctx, head_of(ctx, obj), state);
|
||||
}
|
||||
else if (!pdf_set_field_value(ctx, pdf, obj, value, 1))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the field refused the value");
|
||||
pdf_update_annot(ctx, widget);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, state);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Throws unless `prefix` plus `name` is free for `kind`. */
|
||||
static pdf_obj *check_name(fz_context *ctx, pdf_obj *fields, const char *prefix, const char *name, const char *kind,
|
||||
pdf_obj *self)
|
||||
{
|
||||
pdf_obj *found = NULL;
|
||||
char *full = NULL;
|
||||
if (!name[0] || strchr(name, '.'))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad field name");
|
||||
fz_var(full);
|
||||
fz_try(ctx)
|
||||
{
|
||||
full = fz_asprintf(ctx, "%s%s", prefix, name);
|
||||
found = pdf_lookup_field(ctx, fields, full);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, full);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
if (!found || (self && !pdf_objcmp(ctx, found, self)))
|
||||
return NULL;
|
||||
if (!strcmp(kind, "radio") && pdf_field_type(ctx, found) == PDF_WIDGET_TYPE_RADIOBUTTON &&
|
||||
pdf_dict_get(ctx, found, PDF_NAME(Kids)))
|
||||
return found;
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a field named %s%s exists", prefix, name);
|
||||
}
|
||||
|
||||
/* The full name of `field`'s parent, with a dot. */
|
||||
static char *parent_path(fz_context *ctx, pdf_obj *field)
|
||||
{
|
||||
pdf_obj *parent = pdf_dict_get(ctx, field, PDF_NAME(Parent));
|
||||
char *name = parent ? pdf_load_field_name(ctx, parent) : NULL, *path = NULL;
|
||||
fz_try(ctx)
|
||||
path = fz_asprintf(ctx, "%s%s", name ? name : "", name && name[0] ? "." : "");
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, name);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return path;
|
||||
}
|
||||
|
||||
static void put_options(fz_context *ctx, pdf_obj *field, fz_json *options)
|
||||
{
|
||||
pdf_obj *opt = pdf_dict_put_array(ctx, field, PDF_NAME(Opt), 4);
|
||||
int i, n = fz_json_array_length(ctx, options);
|
||||
for (i = 0; i < n; i++)
|
||||
pdf_array_push_text_string(ctx, opt, fz_json_array_get(ctx, options, i)->u.string);
|
||||
}
|
||||
|
||||
static void check_options(fz_context *ctx, fz_json *options)
|
||||
{
|
||||
int i, n = options ? fz_json_array_length(ctx, options) : 0;
|
||||
if (n == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a choice with no options");
|
||||
for (i = 0; i < n; i++)
|
||||
if (fz_json_array_get(ctx, options, i)->type != FZ_JSON_STRING || !fz_json_array_get(ctx, options, i)->u.string[0])
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad option");
|
||||
}
|
||||
|
||||
/* An on state no button in `group` has. */
|
||||
static pdf_obj *new_state(fz_context *ctx, pdf_obj *group)
|
||||
{
|
||||
pdf_obj *kids = pdf_dict_get(ctx, group, PDF_NAME(Kids));
|
||||
char name[32];
|
||||
int n, i, taken;
|
||||
for (n = pdf_array_len(ctx, kids) + 1;; n++)
|
||||
{
|
||||
snprintf(name, sizeof name, "Choice%d", n);
|
||||
for (i = 0, taken = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
taken |= !strcmp(pdf_to_name(ctx, pdf_button_field_on_state(ctx, pdf_array_get(ctx, kids, i))), name);
|
||||
if (!taken)
|
||||
return pdf_new_name(ctx, name);
|
||||
}
|
||||
}
|
||||
|
||||
/* Adds a field of four kinds, in one step. */
|
||||
int ely_add_field(fz_context *ctx, fz_document *doc, int number, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
pdf_annot *widget = NULL;
|
||||
pdf_obj *state = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(widget);
|
||||
fz_var(state);
|
||||
fz_var(pool);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *spec, *options, *kindj, *namej;
|
||||
const char *kind, *name;
|
||||
pdf_obj *fields, *group, *obj, *mk;
|
||||
fz_rect rect;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
pool = fz_new_pool(ctx);
|
||||
spec = fz_parse_json(ctx, pool, json);
|
||||
kindj = ely_member(ctx, spec, "kind", FZ_JSON_STRING);
|
||||
namej = ely_member(ctx, spec, "name", FZ_JSON_STRING);
|
||||
options = ely_member(ctx, spec, "options", FZ_JSON_ARRAY);
|
||||
if (!kindj || !namej)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a field with no kind or name");
|
||||
kind = kindj->u.string, name = namej->u.string;
|
||||
if (strcmp(kind, "text") && strcmp(kind, "checkbox") && strcmp(kind, "radio") && strcmp(kind, "choice"))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no field kind %s", kind);
|
||||
if (!strcmp(kind, "choice"))
|
||||
check_options(ctx, options);
|
||||
rect = ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 4);
|
||||
group = check_name(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"), "", name, kind, NULL);
|
||||
pdf_begin_operation(ctx, pdf, "Add a field");
|
||||
begun = 1;
|
||||
fields = fields_of(ctx, pdf);
|
||||
widget = pdf_create_annot_raw(ctx, page, PDF_ANNOT_WIDGET);
|
||||
obj = pdf_annot_obj(ctx, widget);
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(P), page->obj);
|
||||
pdf_dict_put_int(ctx, obj, PDF_NAME(F), PDF_ANNOT_IS_PRINT);
|
||||
pdf_dict_put_rect(ctx, obj, PDF_NAME(Rect), fz_transform_rect(rect, ely_to_user(ctx, page)));
|
||||
pdf_dict_put_text_string(ctx, obj, PDF_NAME(DA), "/Helv 0 Tf 0 g");
|
||||
mk = pdf_dict_put_dict(ctx, obj, PDF_NAME(MK), 1);
|
||||
pdf_array_push_real(ctx, pdf_dict_put_array(ctx, mk, PDF_NAME(BC), 1), 0);
|
||||
if (!strcmp(kind, "radio"))
|
||||
{
|
||||
if (!group)
|
||||
{
|
||||
group = pdf_add_new_dict(ctx, pdf, 4);
|
||||
pdf_dict_put(ctx, group, PDF_NAME(FT), PDF_NAME(Btn));
|
||||
pdf_dict_put_int(ctx, group, PDF_NAME(Ff), PDF_BTN_FIELD_IS_RADIO | PDF_BTN_FIELD_IS_NO_TOGGLE_TO_OFF);
|
||||
pdf_dict_put_text_string(ctx, group, PDF_NAME(T), name);
|
||||
pdf_dict_put(ctx, group, PDF_NAME(V), PDF_NAME(Off));
|
||||
pdf_dict_put_array(ctx, group, PDF_NAME(Kids), 2);
|
||||
pdf_array_push_drop(ctx, fields, group);
|
||||
}
|
||||
state = new_state(ctx, group);
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(Parent), group);
|
||||
pdf_array_push(ctx, pdf_dict_get(ctx, group, PDF_NAME(Kids)), obj);
|
||||
}
|
||||
else
|
||||
{
|
||||
pdf_dict_put_text_string(ctx, obj, PDF_NAME(T), name);
|
||||
pdf_array_push(ctx, fields, obj);
|
||||
}
|
||||
if (!strcmp(kind, "text"))
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(FT), PDF_NAME(Tx));
|
||||
else if (!strcmp(kind, "choice"))
|
||||
{
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(FT), PDF_NAME(Ch));
|
||||
pdf_dict_put_int(ctx, obj, PDF_NAME(Ff), PDF_CH_FIELD_IS_COMBO);
|
||||
put_options(ctx, obj, options);
|
||||
}
|
||||
else if (!strcmp(kind, "checkbox"))
|
||||
{
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(FT), PDF_NAME(Btn));
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(V), PDF_NAME(Off));
|
||||
state = pdf_new_name(ctx, "Yes");
|
||||
}
|
||||
/* The on look is drawn under its own state. */
|
||||
if (state)
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(AS), state);
|
||||
pdf_annot_request_resynthesis(ctx, widget);
|
||||
pdf_update_annot(ctx, widget);
|
||||
if (state)
|
||||
pdf_dict_put(ctx, obj, PDF_NAME(AS), PDF_NAME(Off));
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_pool(ctx, pool);
|
||||
pdf_drop_obj(ctx, state);
|
||||
pdf_drop_annot(ctx, widget);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Renames a field or sets its options. */
|
||||
int ely_change_field(fz_context *ctx, fz_document *doc, int number, int id, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
char *path = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(pool);
|
||||
fz_var(path);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *widget;
|
||||
pdf_obj *obj, *head;
|
||||
fz_json *spec, *name, *options;
|
||||
enum pdf_widget_type type;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
widget = ely_find_annot(ctx, page, id, 1);
|
||||
obj = pdf_annot_obj(ctx, widget);
|
||||
head = head_of(ctx, obj);
|
||||
type = pdf_widget_type(ctx, widget);
|
||||
pool = fz_new_pool(ctx);
|
||||
spec = fz_parse_json(ctx, pool, json);
|
||||
name = ely_member(ctx, spec, "name", FZ_JSON_STRING);
|
||||
options = ely_member(ctx, spec, "options", FZ_JSON_ARRAY);
|
||||
if (!name && !options)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no change");
|
||||
if (name)
|
||||
{
|
||||
path = parent_path(ctx, head);
|
||||
check_name(ctx, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"), path, name->u.string, "", head);
|
||||
}
|
||||
if (options && type != PDF_WIDGET_TYPE_COMBOBOX && type != PDF_WIDGET_TYPE_LISTBOX)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "only a choice has options");
|
||||
if (options)
|
||||
check_options(ctx, options);
|
||||
pdf_begin_operation(ctx, pdf, "Change a field");
|
||||
begun = 1;
|
||||
if (name)
|
||||
pdf_dict_put_text_string(ctx, head, PDF_NAME(T), name->u.string);
|
||||
if (options)
|
||||
{
|
||||
int i, kept = 0;
|
||||
const char *value = pdf_to_text_string(ctx, pdf_dict_get_inheritable(ctx, obj, PDF_NAME(V)));
|
||||
for (i = 0; i < fz_json_array_length(ctx, options); i++)
|
||||
kept |= !strcmp(fz_json_array_get(ctx, options, i)->u.string, value);
|
||||
if (obj != head)
|
||||
pdf_dict_del(ctx, obj, PDF_NAME(Opt));
|
||||
put_options(ctx, head, options);
|
||||
if (!kept)
|
||||
pdf_dict_del(ctx, head, PDF_NAME(V));
|
||||
}
|
||||
pdf_annot_request_resynthesis(ctx, widget);
|
||||
pdf_update_annot(ctx, widget);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_pool(ctx, pool);
|
||||
fz_free(ctx, path);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Writes each named node under `kids`, with its full name. */
|
||||
static void write_names(fz_context *ctx, fz_output *out, pdf_obj *kids, const char *prefix, int depth, int *first)
|
||||
{
|
||||
int i;
|
||||
if (depth > 32)
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "a field tree too deep");
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
{
|
||||
pdf_obj *node = pdf_array_get(ctx, kids, i);
|
||||
pdf_obj *t = pdf_dict_get(ctx, node, PDF_NAME(T));
|
||||
char *name = NULL;
|
||||
fz_var(name);
|
||||
fz_try(ctx)
|
||||
{
|
||||
name = t ? fz_asprintf(ctx, "%s%s%s", prefix, prefix[0] ? "." : "", pdf_to_text_string(ctx, t)) : fz_strdup(ctx, prefix);
|
||||
if (t)
|
||||
{
|
||||
fz_write_string(ctx, out, *first ? "" : ",");
|
||||
ely_json_string(ctx, out, name);
|
||||
*first = 0;
|
||||
}
|
||||
write_names(ctx, out, pdf_dict_get(ctx, node, PDF_NAME(Kids)), name, depth + 1, first);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, name);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/* Every field name in the form, widgets or not. */
|
||||
int ely_field_names(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
int first = 1;
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_byte(ctx, out, '[');
|
||||
write_names(ctx, out, pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields"), "", 0, &first);
|
||||
fz_write_byte(ctx, out, ']');
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/* Pages from another PDF, with their links, notes and fields. */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* A source link to its own page `page`. */
|
||||
typedef struct
|
||||
{
|
||||
pdf_obj *annot;
|
||||
pdf_obj *rest;
|
||||
int page;
|
||||
} inner_link;
|
||||
|
||||
/* Takes page references off the source's annotations, links noted. */
|
||||
static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room)
|
||||
{
|
||||
int i, j, n = 0, count = pdf_count_pages(ctx, src);
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots));
|
||||
for (j = 0; j < pdf_array_len(ctx, annots); j++)
|
||||
{
|
||||
pdf_obj *annot = pdf_array_get(ctx, annots, j);
|
||||
pdf_obj *page = ely_target(ctx, src, annot);
|
||||
int to = page ? pdf_lookup_page_number(ctx, src, page) : -1;
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(P));
|
||||
if (to < 0 || n == room)
|
||||
continue;
|
||||
/* The view after the page: /XYZ, /Fit and so on. */
|
||||
pdf_obj *dest = pdf_dict_get(ctx, annot, PDF_NAME(Dest));
|
||||
if (!dest)
|
||||
dest = pdf_dict_getp(ctx, annot, "A/D");
|
||||
if (pdf_is_name(ctx, dest) || pdf_is_string(ctx, dest))
|
||||
dest = pdf_lookup_dest(ctx, src, dest);
|
||||
if (pdf_is_dict(ctx, dest))
|
||||
dest = pdf_dict_get(ctx, dest, PDF_NAME(D));
|
||||
links[n].annot = pdf_keep_obj(ctx, annot);
|
||||
links[n].rest = pdf_copy_array(ctx, dest);
|
||||
pdf_array_delete(ctx, links[n].rest, 0);
|
||||
links[n].page = to;
|
||||
n++;
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(Dest));
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(A));
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The top of the field tree `widget` belongs to. */
|
||||
static pdf_obj *root_field(fz_context *ctx, pdf_obj *widget)
|
||||
{
|
||||
pdf_obj *parent;
|
||||
while ((parent = pdf_dict_get(ctx, widget, PDF_NAME(Parent))) != NULL)
|
||||
widget = parent;
|
||||
return widget;
|
||||
}
|
||||
|
||||
/* The source form's default look and its font renames. */
|
||||
typedef struct
|
||||
{
|
||||
pdf_obj *da;
|
||||
pdf_obj *q;
|
||||
pdf_obj *renames;
|
||||
} form_look;
|
||||
|
||||
static int hex(int c)
|
||||
{
|
||||
return c >= '0' && c <= '9' ? c - '0' : (c | 32) >= 'a' && (c | 32) <= 'f' ? (c | 32) - 'a' + 10 : -1;
|
||||
}
|
||||
|
||||
/* A name token's bytes, #xx escapes decoded. */
|
||||
static void decode_name(const char *token, size_t len, char *out)
|
||||
{
|
||||
size_t i, k = 0;
|
||||
for (i = 0; i < len; i++)
|
||||
{
|
||||
if (token[i] == '#' && i + 2 < len && hex(token[i + 1]) >= 0 && hex(token[i + 2]) >= 0)
|
||||
{
|
||||
out[k++] = (char)(hex(token[i + 1]) * 16 + hex(token[i + 2]));
|
||||
i += 2;
|
||||
}
|
||||
else
|
||||
out[k++] = token[i];
|
||||
}
|
||||
out[k] = 0;
|
||||
}
|
||||
|
||||
/* Writes `name` as a name token, odd bytes escaped. */
|
||||
static void encode_name(fz_context *ctx, fz_buffer *buf, const char *name)
|
||||
{
|
||||
const unsigned char *c;
|
||||
fz_append_byte(ctx, buf, '/');
|
||||
for (c = (const unsigned char *)name; *c; c++)
|
||||
if (*c < 0x21 || *c > 0x7e || strchr("#/[]()<>{}%", *c))
|
||||
fz_append_printf(ctx, buf, "#%02X", *c);
|
||||
else
|
||||
fz_append_byte(ctx, buf, *c);
|
||||
}
|
||||
|
||||
/* `name`, or `name` and `n` when n is above one. */
|
||||
static char *candidate(fz_context *ctx, const char *name, const char *gap, int n)
|
||||
{
|
||||
return n == 1 ? fz_strdup(ctx, name) : fz_asprintf(ctx, "%s%s%d", name, gap, n);
|
||||
}
|
||||
|
||||
/* Merges the source's form fonts; taken names get numbered. */
|
||||
static void merge_fonts(fz_context *ctx, pdf_document *pdf, pdf_graft_map *map, pdf_document *src, form_look *look)
|
||||
{
|
||||
pdf_obj *from = pdf_dict_getp(ctx, pdf_trailer(ctx, src), "Root/AcroForm/DR/Font");
|
||||
pdf_obj *to, *font = NULL;
|
||||
char *fresh = NULL;
|
||||
int i, n;
|
||||
fz_var(font);
|
||||
fz_var(fresh);
|
||||
if (!from)
|
||||
return;
|
||||
to = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/DR/Font");
|
||||
if (!to)
|
||||
{
|
||||
to = pdf_new_dict(ctx, pdf, 4);
|
||||
pdf_dict_putp_drop(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/DR/Font", to);
|
||||
}
|
||||
fz_try(ctx)
|
||||
for (i = 0; i < pdf_dict_len(ctx, from); i++)
|
||||
{
|
||||
const char *name = pdf_to_name(ctx, pdf_dict_get_key(ctx, from, i));
|
||||
font = pdf_graft_mapped_object(ctx, map, pdf_dict_get_val(ctx, from, i));
|
||||
for (n = 1;; n++)
|
||||
{
|
||||
pdf_obj *held;
|
||||
fz_free(ctx, fresh);
|
||||
fresh = candidate(ctx, name, "_", n);
|
||||
held = pdf_dict_gets(ctx, to, fresh);
|
||||
if (!held)
|
||||
pdf_dict_puts(ctx, to, fresh, font);
|
||||
if (!held || (pdf_is_indirect(ctx, held) && pdf_to_num(ctx, held) == pdf_to_num(ctx, font)))
|
||||
break;
|
||||
}
|
||||
if (n > 1)
|
||||
pdf_dict_puts_drop(ctx, look->renames, name, pdf_new_name(ctx, fresh));
|
||||
pdf_drop_obj(ctx, font);
|
||||
font = NULL;
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, font);
|
||||
fz_free(ctx, fresh);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* `node`'s /DA with renamed fonts renamed. */
|
||||
static void rename_fonts(fz_context *ctx, pdf_obj *node, pdf_obj *renames)
|
||||
{
|
||||
const char *da = pdf_dict_get_text_string(ctx, node, PDF_NAME(DA));
|
||||
fz_buffer *buf = NULL;
|
||||
char *name = NULL;
|
||||
fz_var(buf);
|
||||
fz_var(name);
|
||||
if (!*da || !pdf_dict_len(ctx, renames))
|
||||
return;
|
||||
fz_try(ctx)
|
||||
{
|
||||
unsigned char *data;
|
||||
size_t len;
|
||||
buf = fz_new_buffer(ctx, 64);
|
||||
while (*da)
|
||||
{
|
||||
size_t word = 1;
|
||||
pdf_obj *to;
|
||||
if (*da != '/')
|
||||
{
|
||||
fz_append_byte(ctx, buf, *da++);
|
||||
continue;
|
||||
}
|
||||
while (da[word] && !strchr(" \t\r\n\f/[]()<>{}%", da[word]))
|
||||
word++;
|
||||
fz_free(ctx, name);
|
||||
name = fz_malloc(ctx, word);
|
||||
decode_name(da + 1, word - 1, name);
|
||||
to = pdf_dict_gets(ctx, renames, name);
|
||||
if (to)
|
||||
encode_name(ctx, buf, pdf_to_name(ctx, to));
|
||||
else
|
||||
fz_append_data(ctx, buf, da, word);
|
||||
da += word;
|
||||
}
|
||||
len = fz_buffer_storage(ctx, buf, &data);
|
||||
pdf_dict_put_string(ctx, node, PDF_NAME(DA), (const char *)data, len);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_free(ctx, name);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Gives a field tree the source's look, fonts renamed. */
|
||||
static void restyle(fz_context *ctx, pdf_obj *node, form_look *look, int root)
|
||||
{
|
||||
pdf_obj *kids = pdf_dict_get(ctx, node, PDF_NAME(Kids));
|
||||
int i;
|
||||
/* The form's defaults a field inherits: look and alignment. */
|
||||
if (root && look->da && !pdf_dict_get(ctx, node, PDF_NAME(DA)))
|
||||
pdf_dict_put(ctx, node, PDF_NAME(DA), look->da);
|
||||
if (root && look->q && !pdf_dict_get(ctx, node, PDF_NAME(Q)))
|
||||
pdf_dict_put(ctx, node, PDF_NAME(Q), look->q);
|
||||
rename_fonts(ctx, node, look->renames);
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
restyle(ctx, pdf_array_get(ctx, kids, i), look, 0);
|
||||
}
|
||||
|
||||
/* Joins `field` to the form; a taken name gets numbered. */
|
||||
static void add_field(fz_context *ctx, pdf_document *pdf, pdf_obj *field, form_look *look)
|
||||
{
|
||||
pdf_obj *fields = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields");
|
||||
const char *name = pdf_dict_get_text_string(ctx, field, PDF_NAME(T));
|
||||
char *renamed = NULL;
|
||||
int i, n, taken;
|
||||
fz_var(renamed);
|
||||
if (!fields)
|
||||
{
|
||||
fields = pdf_new_array(ctx, pdf, 1);
|
||||
pdf_dict_putp_drop(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields", fields);
|
||||
}
|
||||
for (i = 0; i < pdf_array_len(ctx, fields); i++)
|
||||
if (pdf_to_num(ctx, pdf_array_get(ctx, fields, i)) == pdf_to_num(ctx, field))
|
||||
return;
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (n = 1, taken = 1; taken; n++)
|
||||
{
|
||||
fz_free(ctx, renamed);
|
||||
renamed = candidate(ctx, name, " ", n);
|
||||
for (taken = 0, i = 0; i < pdf_array_len(ctx, fields) && !taken; i++)
|
||||
taken = !strcmp(pdf_dict_get_text_string(ctx, pdf_array_get(ctx, fields, i), PDF_NAME(T)), renamed);
|
||||
}
|
||||
if (strcmp(renamed, name))
|
||||
pdf_dict_put_text_string(ctx, field, PDF_NAME(T), renamed);
|
||||
restyle(ctx, field, look, 1);
|
||||
pdf_array_push(ctx, fields, field);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, renamed);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Grafts each page's annotations and reconnects them. */
|
||||
static void reconnect(fz_context *ctx, pdf_document *pdf, pdf_graft_map *map, pdf_document *src, int at, form_look *look)
|
||||
{
|
||||
int i, j, count = pdf_count_pages(ctx, src);
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots));
|
||||
pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, at + i);
|
||||
pdf_obj *grafted;
|
||||
if (!annots)
|
||||
continue;
|
||||
grafted = pdf_graft_mapped_object(ctx, map, annots);
|
||||
pdf_dict_put_drop(ctx, page, PDF_NAME(Annots), grafted);
|
||||
for (j = 0; j < pdf_array_len(ctx, grafted); j++)
|
||||
{
|
||||
pdf_obj *annot = pdf_array_get(ctx, grafted, j);
|
||||
pdf_dict_put(ctx, annot, PDF_NAME(P), page);
|
||||
if (pdf_name_eq(ctx, pdf_dict_get(ctx, annot, PDF_NAME(Subtype)), PDF_NAME(Widget)))
|
||||
add_field(ctx, pdf, root_field(ctx, annot), look);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Annotations and form look come over; inner links point home. */
|
||||
static void graft_annots(fz_context *ctx, pdf_document *pdf, pdf_graft_map *map, pdf_document *src, int at,
|
||||
inner_link *links, int n)
|
||||
{
|
||||
form_look look = { NULL, NULL, NULL };
|
||||
pdf_obj *da = pdf_dict_getp(ctx, pdf_trailer(ctx, src), "Root/AcroForm/DA");
|
||||
pdf_obj *q = pdf_dict_getp(ctx, pdf_trailer(ctx, src), "Root/AcroForm/Q");
|
||||
int i, j;
|
||||
fz_var(look.da);
|
||||
fz_var(look.renames);
|
||||
fz_var(look.q);
|
||||
fz_try(ctx)
|
||||
{
|
||||
look.renames = pdf_new_dict(ctx, pdf, 1);
|
||||
look.da = da ? pdf_graft_mapped_object(ctx, map, da) : NULL;
|
||||
look.q = q ? pdf_graft_mapped_object(ctx, map, q) : NULL;
|
||||
merge_fonts(ctx, pdf, map, src, &look);
|
||||
reconnect(ctx, pdf, map, src, at, &look);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, look.renames);
|
||||
pdf_drop_obj(ctx, look.da);
|
||||
pdf_drop_obj(ctx, look.q);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_obj *annot = pdf_graft_mapped_object(ctx, map, links[i].annot);
|
||||
pdf_obj *dest = pdf_new_array(ctx, pdf, 1 + pdf_array_len(ctx, links[i].rest));
|
||||
pdf_array_push(ctx, dest, pdf_lookup_page_obj(ctx, pdf, at + links[i].page));
|
||||
for (j = 0; j < pdf_array_len(ctx, links[i].rest); j++)
|
||||
pdf_array_push(ctx, dest, pdf_array_get(ctx, links[i].rest, j));
|
||||
pdf_dict_put_drop(ctx, annot, PDF_NAME(Dest), dest);
|
||||
pdf_drop_obj(ctx, annot);
|
||||
}
|
||||
}
|
||||
|
||||
/* Every page of the PDF at `path`, inserted at `at`. */
|
||||
int ely_insert_file(fz_context *ctx, fz_document *doc, int at, const char *path, int *count)
|
||||
{
|
||||
pdf_document *pdf = NULL, *src = NULL;
|
||||
pdf_graft_map *map = NULL;
|
||||
inner_link *links = NULL;
|
||||
int begun = 0, i, n = 0, room = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(src);
|
||||
fz_var(map);
|
||||
fz_var(links);
|
||||
fz_var(n);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
if (at < 0 || at > pdf_count_pages(ctx, pdf))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no place %d to insert at", at);
|
||||
src = pdf_open_document(ctx, path);
|
||||
if (pdf_needs_password(ctx, src))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the file to insert is locked");
|
||||
*count = pdf_count_pages(ctx, src);
|
||||
for (i = 0; i < *count; i++)
|
||||
room += pdf_array_len(ctx, pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots)));
|
||||
links = fz_calloc(ctx, room + 1, sizeof *links);
|
||||
n = unhook(ctx, src, links, room);
|
||||
map = pdf_new_graft_map(ctx, pdf);
|
||||
pdf_begin_operation(ctx, pdf, "Insert pages");
|
||||
begun = 1;
|
||||
for (i = 0; i < *count; i++)
|
||||
pdf_graft_mapped_page(ctx, map, at + i, src, i);
|
||||
graft_annots(ctx, pdf, map, src, at, links, n);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
for (i = 0; links && i < n; i++)
|
||||
{
|
||||
pdf_drop_obj(ctx, links[i].annot);
|
||||
pdf_drop_obj(ctx, links[i].rest);
|
||||
}
|
||||
fz_free(ctx, links);
|
||||
pdf_drop_graft_map(ctx, map);
|
||||
pdf_drop_document(ctx, src);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/* Document properties and attachments (PDF-07). */
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
static const char *shown[] = { "Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate",
|
||||
"ModDate", NULL };
|
||||
/* Only these change; the rest the producer wrote. */
|
||||
static const char *editable[] = { "Title", "Author", "Subject", "Keywords", NULL };
|
||||
|
||||
/* Properties as JSON; null when the file has none. */
|
||||
int ely_metadata(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
char *value = NULL;
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_var(value);
|
||||
fz_try(ctx)
|
||||
{
|
||||
char key[32];
|
||||
int i, n;
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_byte(ctx, out, '{');
|
||||
for (i = 0; shown[i]; i++)
|
||||
{
|
||||
fz_snprintf(key, sizeof key, "info:%s", shown[i]);
|
||||
fz_write_printf(ctx, out, "%s\"%s\":", i ? "," : "", shown[i]);
|
||||
n = fz_lookup_metadata(ctx, doc, key, NULL, 0);
|
||||
if (n < 0)
|
||||
{
|
||||
fz_write_string(ctx, out, "null");
|
||||
continue;
|
||||
}
|
||||
fz_free(ctx, value);
|
||||
value = NULL;
|
||||
value = fz_malloc(ctx, n);
|
||||
fz_lookup_metadata(ctx, doc, key, value, n);
|
||||
ely_json_string(ctx, out, value);
|
||||
}
|
||||
fz_write_byte(ctx, out, '}');
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, value);
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Sets the given properties, in one step. */
|
||||
int ely_set_metadata(fz_context *ctx, fz_document *doc, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(pool);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *spec;
|
||||
fz_json_object *field;
|
||||
char key[32];
|
||||
int i;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
pool = fz_new_pool(ctx);
|
||||
spec = fz_parse_json(ctx, pool, json);
|
||||
if (spec->type != FZ_JSON_OBJECT || !spec->u.object)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no properties to set");
|
||||
for (field = spec->u.object; field; field = field->next)
|
||||
{
|
||||
for (i = 0; editable[i] && strcmp(editable[i], field->key); i++)
|
||||
continue;
|
||||
if (!editable[i] || field->value->type != FZ_JSON_STRING)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a property that does not change: %s", field->key);
|
||||
}
|
||||
pdf_begin_operation(ctx, pdf, "Set properties");
|
||||
begun = 1;
|
||||
for (field = spec->u.object; field; field = field->next)
|
||||
{
|
||||
fz_snprintf(key, sizeof key, "info:%s", field->key);
|
||||
fz_set_metadata(ctx, doc, key, field->value->u.string);
|
||||
}
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_pool(ctx, pool);
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int by_name(const void *a, const void *b)
|
||||
{
|
||||
return strcmp(*(const char *const *)a, *(const char *const *)b);
|
||||
}
|
||||
|
||||
/* The embedded files, by name; NULL names end it. */
|
||||
static const char **names_of(fz_context *ctx, pdf_obj *tree, int *count)
|
||||
{
|
||||
const char **names;
|
||||
int i, n = pdf_dict_len(ctx, tree);
|
||||
names = fz_malloc_array(ctx, n + 1, const char *);
|
||||
for (i = 0; i < n; i++)
|
||||
names[i] = pdf_to_name(ctx, pdf_dict_get_key(ctx, tree, i));
|
||||
names[n] = NULL;
|
||||
qsort(names, n, sizeof *names, by_name);
|
||||
*count = n;
|
||||
return names;
|
||||
}
|
||||
|
||||
/* An embedded file's length: its /Size, else its bytes. */
|
||||
static int64_t file_size(fz_context *ctx, pdf_obj *fs)
|
||||
{
|
||||
pdf_obj *ef = pdf_dict_get(ctx, fs, PDF_NAME(EF));
|
||||
pdf_obj *file = pdf_dict_get(ctx, ef, PDF_NAME(F)) ? pdf_dict_get(ctx, ef, PDF_NAME(F)) : pdf_dict_get(ctx, ef, PDF_NAME(UF));
|
||||
pdf_obj *size = pdf_dict_getl(ctx, file, PDF_NAME(Params), PDF_NAME(Size), NULL);
|
||||
fz_buffer *bytes;
|
||||
int64_t n;
|
||||
if (pdf_is_int(ctx, size))
|
||||
return pdf_to_int64(ctx, size);
|
||||
bytes = pdf_load_embedded_file_contents(ctx, fs);
|
||||
n = (int64_t)fz_buffer_storage(ctx, bytes, NULL);
|
||||
fz_drop_buffer(ctx, bytes);
|
||||
return n;
|
||||
}
|
||||
|
||||
static void write_file(fz_context *ctx, fz_output *out, const char *key, const char *name, int page, pdf_obj *fs, int *first)
|
||||
{
|
||||
pdf_filespec_params params;
|
||||
pdf_get_filespec_params(ctx, fs, ¶ms);
|
||||
fz_write_string(ctx, out, *first ? "{\"key\":" : ",{\"key\":");
|
||||
*first = 0;
|
||||
ely_json_string(ctx, out, key);
|
||||
fz_write_string(ctx, out, ",\"name\":");
|
||||
ely_json_string(ctx, out, name);
|
||||
fz_write_printf(ctx, out, ",\"page\":%d,\"size\":%ld,\"created\":%ld,\"modified\":%ld,\"type\":", page,
|
||||
(long)file_size(ctx, fs), (long)params.created, (long)params.modified);
|
||||
ely_json_string(ctx, out, params.mimetype ? params.mimetype : "");
|
||||
fz_write_string(ctx, out, ",\"note\":");
|
||||
ely_json_string(ctx, out, pdf_dict_get_text_string(ctx, fs, PDF_NAME(Desc)));
|
||||
fz_write_byte(ctx, out, '}');
|
||||
}
|
||||
|
||||
/* The page's file annotations, as attachments. */
|
||||
static void write_page_files(fz_context *ctx, fz_output *out, pdf_document *pdf, int number, int *first)
|
||||
{
|
||||
pdf_page *page = pdf_load_page(ctx, pdf, number);
|
||||
char key[48];
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *annot;
|
||||
for (annot = pdf_first_annot(ctx, page); annot; annot = pdf_next_annot(ctx, annot))
|
||||
{
|
||||
pdf_obj *fs;
|
||||
pdf_filespec_params params;
|
||||
if (pdf_annot_type(ctx, annot) != PDF_ANNOT_FILE_ATTACHMENT)
|
||||
continue;
|
||||
fs = pdf_annot_filespec(ctx, annot);
|
||||
if (!pdf_is_embedded_file(ctx, fs))
|
||||
continue;
|
||||
pdf_get_filespec_params(ctx, fs, ¶ms);
|
||||
fz_snprintf(key, sizeof key, "a:%d:%d", number, pdf_to_num(ctx, pdf_annot_obj(ctx, annot)));
|
||||
write_file(ctx, out, key, params.filename ? params.filename : "", number, fs, first);
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Every embedded file: the tree's by name, then pages'. */
|
||||
int ely_attachments(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len)
|
||||
{
|
||||
pdf_obj *tree = NULL;
|
||||
const char **names = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
char *key = NULL;
|
||||
fz_var(tree);
|
||||
fz_var(names);
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_var(key);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
int i, n, first = 1;
|
||||
tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles));
|
||||
names = names_of(ctx, tree, &n);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_byte(ctx, out, '[');
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_obj *fs = pdf_dict_gets(ctx, tree, names[i]);
|
||||
if (!pdf_is_embedded_file(ctx, fs))
|
||||
continue;
|
||||
fz_free(ctx, key);
|
||||
key = NULL;
|
||||
key = fz_asprintf(ctx, "n:%s", names[i]);
|
||||
write_file(ctx, out, key, names[i], -1, fs, &first);
|
||||
}
|
||||
for (i = 0; i < pdf_count_pages(ctx, pdf); i++)
|
||||
write_page_files(ctx, out, pdf, i, &first);
|
||||
fz_write_byte(ctx, out, ']');
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, key);
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_free(ctx, names);
|
||||
pdf_drop_obj(ctx, tree);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The file `key` names; a page annotation's loads its page. */
|
||||
static pdf_obj *file_of(fz_context *ctx, pdf_document *pdf, const char *key, pdf_obj *tree, pdf_page **page, pdf_annot **annot)
|
||||
{
|
||||
pdf_obj *fs = NULL;
|
||||
int number, id;
|
||||
if (!strncmp(key, "n:", 2))
|
||||
fs = pdf_dict_gets(ctx, tree, key + 2);
|
||||
else if (sscanf(key, "a:%d:%d", &number, &id) == 2 && number >= 0 && number < pdf_count_pages(ctx, pdf))
|
||||
{
|
||||
*page = pdf_load_page(ctx, pdf, number);
|
||||
*annot = ely_find_annot(ctx, *page, id, 0);
|
||||
if (pdf_annot_type(ctx, *annot) == PDF_ANNOT_FILE_ATTACHMENT)
|
||||
fs = pdf_annot_filespec(ctx, *annot);
|
||||
}
|
||||
if (!pdf_is_embedded_file(ctx, fs))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no attachment %s", key);
|
||||
return fs;
|
||||
}
|
||||
|
||||
/* An embedded file's bytes. */
|
||||
int ely_attachment(fz_context *ctx, fz_document *doc, const char *key, unsigned char **data, size_t *len)
|
||||
{
|
||||
pdf_obj *tree = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(tree);
|
||||
fz_var(page);
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
pdf_annot *annot = NULL;
|
||||
tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles));
|
||||
unsigned char *bytes;
|
||||
buf = pdf_load_embedded_file_contents(ctx, file_of(ctx, pdf, key, tree, &page, &annot));
|
||||
/* A copy: the document may hold this buffer. */
|
||||
*len = fz_buffer_storage(ctx, buf, &bytes);
|
||||
*data = fz_malloc(ctx, *len ? *len : 1);
|
||||
memcpy(*data, bytes, *len);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
pdf_drop_obj(ctx, tree);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Writes `tree` back flat, its names in order. */
|
||||
static void put_tree(fz_context *ctx, pdf_document *pdf, pdf_obj *tree)
|
||||
{
|
||||
pdf_obj *root = pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root));
|
||||
pdf_obj *names = pdf_dict_get(ctx, root, PDF_NAME(Names)), *list;
|
||||
const char **sorted = NULL;
|
||||
int i, n;
|
||||
fz_var(sorted);
|
||||
if (!names)
|
||||
names = pdf_dict_put_dict(ctx, root, PDF_NAME(Names), 1);
|
||||
list = pdf_dict_put_array(ctx, pdf_dict_put_dict(ctx, names, PDF_NAME(EmbeddedFiles), 1), PDF_NAME(Names), 8);
|
||||
fz_try(ctx)
|
||||
{
|
||||
sorted = names_of(ctx, tree, &n);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_array_push_text_string(ctx, list, sorted[i]);
|
||||
pdf_array_push(ctx, list, pdf_dict_gets(ctx, tree, sorted[i]));
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, sorted);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Embeds the file at `path` as `name`, in one step. */
|
||||
int ely_attach(fz_context *ctx, fz_document *doc, const char *name, const char *path)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_obj *tree = NULL, *fs = NULL;
|
||||
fz_buffer *contents = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(tree);
|
||||
fz_var(fs);
|
||||
fz_var(contents);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
if (!name[0])
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "an attachment with no name");
|
||||
tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles));
|
||||
if (!tree)
|
||||
tree = pdf_new_dict(ctx, pdf, 1);
|
||||
if (pdf_dict_gets(ctx, tree, name))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "an attachment named %s exists", name);
|
||||
contents = fz_read_file(ctx, path);
|
||||
pdf_begin_operation(ctx, pdf, "Attach a file");
|
||||
begun = 1;
|
||||
fs = pdf_add_embedded_file(ctx, pdf, name, NULL, contents, -1, -1, 1);
|
||||
pdf_dict_puts(ctx, tree, name, fs);
|
||||
put_tree(ctx, pdf, tree);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, fs);
|
||||
fz_drop_buffer(ctx, contents);
|
||||
pdf_drop_obj(ctx, tree);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Removes the file `key`: a tree entry or its annotation. */
|
||||
int ely_detach(fz_context *ctx, fz_document *doc, const char *key)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_obj *tree = NULL;
|
||||
pdf_page *page = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(tree);
|
||||
fz_var(page);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_annot *annot = NULL;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
tree = pdf_load_name_tree(ctx, pdf, PDF_NAME(EmbeddedFiles));
|
||||
file_of(ctx, pdf, key, tree, &page, &annot);
|
||||
pdf_begin_operation(ctx, pdf, "Remove an attachment");
|
||||
begun = 1;
|
||||
if (annot)
|
||||
pdf_delete_annot(ctx, page, annot);
|
||||
else
|
||||
{
|
||||
pdf_dict_dels(ctx, tree, key + 2);
|
||||
put_tree(ctx, pdf, tree);
|
||||
}
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
pdf_drop_obj(ctx, tree);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/* MuPDF behind plain return codes: fz_try never crosses into Rust. */
|
||||
|
||||
#include <mupdf/fitz.h>
|
||||
#include <mupdf/pdf.h>
|
||||
#include <string.h>
|
||||
|
||||
static char last_error[512];
|
||||
|
||||
/* Takes the caught error to Rust; MuPDF counts it handled. */
|
||||
int ely_fail(fz_context *ctx)
|
||||
{
|
||||
int code;
|
||||
fz_strlcpy(last_error, fz_convert_error(ctx, &code), sizeof last_error);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* A second failure while handling the first. */
|
||||
void ely_fail_also(fz_context *ctx, const char *what)
|
||||
{
|
||||
char also[256];
|
||||
int code;
|
||||
fz_snprintf(also, sizeof also, "; %s: %s", what, fz_convert_error(ctx, &code));
|
||||
fz_strlcat(last_error, also, sizeof last_error);
|
||||
}
|
||||
|
||||
const char *ely_error(void)
|
||||
{
|
||||
return last_error;
|
||||
}
|
||||
|
||||
fz_context *ely_init(void)
|
||||
{
|
||||
fz_context *ctx = fz_new_context(NULL, NULL, FZ_STORE_DEFAULT);
|
||||
if (!ctx)
|
||||
{
|
||||
fz_strlcpy(last_error, "no MuPDF context", sizeof last_error);
|
||||
return NULL;
|
||||
}
|
||||
fz_try(ctx)
|
||||
fz_register_document_handlers(ctx);
|
||||
fz_catch(ctx)
|
||||
{
|
||||
ely_fail(ctx);
|
||||
fz_drop_context(ctx);
|
||||
return NULL;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/* What MuPDF warned while opening, for repair reports. */
|
||||
static char warned[4096];
|
||||
|
||||
typedef struct
|
||||
{
|
||||
fz_warning_cb *next;
|
||||
void *user;
|
||||
} passing;
|
||||
|
||||
static void keep_warning(void *user, const char *message)
|
||||
{
|
||||
passing *p = user;
|
||||
if (strlen(warned) + strlen(message) + 2 < sizeof warned)
|
||||
{
|
||||
fz_strlcat(warned, message, sizeof warned);
|
||||
fz_strlcat(warned, "\n", sizeof warned);
|
||||
}
|
||||
if (p->next)
|
||||
p->next(p->user, message);
|
||||
}
|
||||
|
||||
static passing watching;
|
||||
static int watched;
|
||||
|
||||
/* Collects MuPDF's warnings until `ely_watch_end`. */
|
||||
void ely_watch_begin(fz_context *ctx)
|
||||
{
|
||||
watching.next = fz_warning_callback(ctx, &watching.user);
|
||||
warned[0] = 0;
|
||||
watched = 1;
|
||||
fz_set_warning_callback(ctx, keep_warning, &watching);
|
||||
}
|
||||
|
||||
/* 1 if MuPDF warned since `ely_watch_begin`. */
|
||||
int ely_watch_end(fz_context *ctx)
|
||||
{
|
||||
if (!watched)
|
||||
return 0;
|
||||
fz_flush_warnings(ctx);
|
||||
fz_set_warning_callback(ctx, watching.next, watching.user);
|
||||
watched = 0;
|
||||
return warned[0] != 0;
|
||||
}
|
||||
|
||||
int ely_open(fz_context *ctx, const char *path, fz_document **out)
|
||||
{
|
||||
ely_watch_begin(ctx);
|
||||
fz_try(ctx)
|
||||
*out = fz_open_document(ctx, path);
|
||||
fz_always(ctx)
|
||||
ely_watch_end(ctx);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Reads every object and non-image stream; 1 if any warned. */
|
||||
int ely_scan(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
pdf_document *pdf = pdf_document_from_fz_document(ctx, doc);
|
||||
int i;
|
||||
if (!pdf)
|
||||
return 0;
|
||||
ely_watch_begin(ctx);
|
||||
for (i = 1; i < pdf_xref_len(ctx, pdf); i++)
|
||||
{
|
||||
pdf_obj *obj = NULL;
|
||||
fz_buffer *bytes = NULL;
|
||||
fz_var(obj);
|
||||
fz_var(bytes);
|
||||
fz_try(ctx)
|
||||
{
|
||||
obj = pdf_new_indirect(ctx, pdf, i, 0);
|
||||
pdf_cache_object(ctx, pdf, i);
|
||||
/* Pictures are checked as they draw, or before an overwrite. */
|
||||
if (pdf_is_stream(ctx, obj) && !pdf_name_eq(ctx, pdf_dict_get(ctx, obj, PDF_NAME(Subtype)), PDF_NAME(Image)))
|
||||
bytes = pdf_load_stream_number(ctx, pdf, i);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, bytes);
|
||||
pdf_drop_obj(ctx, obj);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
{
|
||||
/* An unreadable object goes in the report. */
|
||||
fz_warn(ctx, "object %d: %s", i, fz_caught_message(ctx));
|
||||
fz_ignore_error(ctx);
|
||||
}
|
||||
}
|
||||
return ely_watch_end(ctx);
|
||||
}
|
||||
|
||||
/* The warnings of the last watch. */
|
||||
const char *ely_open_warnings(void)
|
||||
{
|
||||
return warned;
|
||||
}
|
||||
|
||||
void ely_close(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
fz_drop_document(ctx, doc);
|
||||
}
|
||||
|
||||
/* 1 locked, 0 open, -1 an error. */
|
||||
int ely_needs_password(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
int locked = 0;
|
||||
fz_try(ctx)
|
||||
locked = fz_needs_password(ctx, doc);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return locked;
|
||||
}
|
||||
|
||||
/* Access bits: 4 owner; 0 refused, -1 an error. */
|
||||
int ely_authenticate(fz_context *ctx, fz_document *doc, const char *password)
|
||||
{
|
||||
int access = 0;
|
||||
fz_try(ctx)
|
||||
access = fz_authenticate_password(ctx, doc, password);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return access;
|
||||
}
|
||||
|
||||
int ely_count_pages(fz_context *ctx, fz_document *doc, int *out)
|
||||
{
|
||||
fz_try(ctx)
|
||||
*out = fz_count_pages(ctx, doc);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ely_page_bounds(fz_context *ctx, fz_document *doc, int number, float out[4])
|
||||
{
|
||||
fz_page *page = NULL;
|
||||
fz_var(page);
|
||||
fz_try(ctx)
|
||||
{
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
fz_rect box = fz_bound_page(ctx, page);
|
||||
out[0] = box.x0; out[1] = box.y0; out[2] = box.x1; out[3] = box.y1;
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_page(ctx, page);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The page turned, scaled and moved to the origin. */
|
||||
static fz_matrix placed(fz_rect box, float scale, int rotate)
|
||||
{
|
||||
fz_matrix ctm = fz_concat(fz_rotate(rotate), fz_scale(scale, scale));
|
||||
fz_rect turned = fz_transform_rect(box, ctm);
|
||||
return fz_concat(ctm, fz_translate(-turned.x0, -turned.y0));
|
||||
}
|
||||
|
||||
/* Paints device pixels x, y, w, h as BGRA. */
|
||||
int ely_render(fz_context *ctx, fz_document *doc, int number, float scale, int rotate,
|
||||
const int tile[4], int dark, unsigned char *bgra)
|
||||
{
|
||||
fz_page *page = NULL;
|
||||
fz_pixmap *pix = NULL;
|
||||
fz_device *dev = NULL;
|
||||
fz_var(page);
|
||||
fz_var(pix);
|
||||
fz_var(dev);
|
||||
fz_try(ctx)
|
||||
{
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
fz_matrix ctm = placed(fz_bound_page(ctx, page), scale, rotate);
|
||||
fz_irect area = fz_make_irect(tile[0], tile[1], tile[0] + tile[2], tile[1] + tile[3]);
|
||||
pix = fz_new_pixmap_with_bbox(ctx, fz_device_bgr(ctx), area, NULL, 0);
|
||||
fz_clear_pixmap_with_value(ctx, pix, 0xff);
|
||||
dev = fz_new_draw_device(ctx, fz_identity, pix);
|
||||
fz_run_page(ctx, page, dev, ctm, NULL);
|
||||
fz_close_device(ctx, dev);
|
||||
if (dark)
|
||||
fz_invert_pixmap_luminance(ctx, pix);
|
||||
int stride = (int)fz_pixmap_stride(ctx, pix);
|
||||
unsigned char *rows = fz_pixmap_samples(ctx, pix);
|
||||
for (int y = 0; y < tile[3]; y++)
|
||||
for (int x = 0; x < tile[2]; x++)
|
||||
{
|
||||
const unsigned char *from = rows + y * stride + x * 3;
|
||||
unsigned char *to = bgra + ((size_t)y * tile[2] + x) * 4;
|
||||
to[0] = from[0]; to[1] = from[1]; to[2] = from[2]; to[3] = 0xff;
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_device(ctx, dev);
|
||||
fz_drop_pixmap(ctx, pix);
|
||||
fz_drop_page(ctx, page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* One character: line, code point, box in page points. */
|
||||
typedef struct
|
||||
{
|
||||
int line;
|
||||
int c;
|
||||
float x0, y0, x1, y1;
|
||||
} ely_char;
|
||||
|
||||
static void add_chars(fz_context *ctx, fz_buffer *buf, fz_stext_block *block, int *line)
|
||||
{
|
||||
for (; block; block = block->next)
|
||||
{
|
||||
if (block->type == FZ_STEXT_BLOCK_STRUCT && block->u.s.down)
|
||||
add_chars(ctx, buf, block->u.s.down->first_block, line);
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
for (fz_stext_line *l = block->u.t.first_line; l; l = l->next, ++*line)
|
||||
for (fz_stext_char *ch = l->first_char; ch; ch = ch->next)
|
||||
{
|
||||
fz_rect box = fz_rect_from_quad(ch->quad);
|
||||
ely_char one = { *line, ch->c, box.x0, box.y0, box.x1, box.y1 };
|
||||
fz_append_data(ctx, buf, &one, sizeof one);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The page's characters in reading order; free with ely_free. */
|
||||
int ely_text(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_page *page = NULL;
|
||||
fz_stext_page *text = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(page);
|
||||
fz_var(text);
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
text = fz_new_stext_page_from_page(ctx, page, NULL);
|
||||
buf = fz_new_buffer(ctx, 4096);
|
||||
int line = 0;
|
||||
add_chars(ctx, buf, text->first_block, &line);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_drop_page(ctx, page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Each hit: its quad count, then those quads. */
|
||||
static int add_hit(fz_context *ctx, void *opaque, int count, fz_quad *quads)
|
||||
{
|
||||
fz_buffer *buf = opaque;
|
||||
fz_append_data(ctx, buf, &count, sizeof count);
|
||||
fz_append_data(ctx, buf, quads, sizeof *quads * count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Every hit on a page; free with ely_free. */
|
||||
int ely_search(fz_context *ctx, fz_document *doc, int number, const char *needle,
|
||||
unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_page *page = NULL;
|
||||
fz_stext_page *text = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(page);
|
||||
fz_var(text);
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
text = fz_new_stext_page_from_page(ctx, page, NULL);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
fz_search_stext_page_cb(ctx, text, needle, add_hit, buf);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_drop_page(ctx, page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void outline_json(fz_context *ctx, fz_output *out, fz_document *doc, fz_outline *item)
|
||||
{
|
||||
fz_write_string(ctx, out, "[");
|
||||
for (; item; item = item->next)
|
||||
{
|
||||
int page = item->page.page >= 0 ? fz_page_number_from_location(ctx, doc, item->page) : -1;
|
||||
fz_write_printf(ctx, out, "{%q:%q,%q:%d,%q:%g,%q:", "title", item->title ? item->title : "",
|
||||
"page", page, "y", item->y, "down");
|
||||
outline_json(ctx, out, doc, item->down);
|
||||
fz_write_string(ctx, out, item->next ? "}," : "}");
|
||||
}
|
||||
fz_write_string(ctx, out, "]");
|
||||
}
|
||||
|
||||
/* Bookmarks as JSON: title, page or -1, y, down. */
|
||||
int ely_outline(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_outline *outline = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(outline);
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
outline = fz_load_outline(ctx, doc);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
outline_json(ctx, out, doc, outline);
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_outline(ctx, outline);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ely_free(fz_context *ctx, void *data)
|
||||
{
|
||||
fz_free(ctx, data);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* OCR: recognise a page, add a hidden text layer (PDF-08). */
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
static const char to_unicode[] =
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n"
|
||||
"/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
|
||||
"/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n"
|
||||
"1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n"
|
||||
"1 beginbfrange\n<0000> <FFFF> <0000>\nendbfrange\n"
|
||||
"endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
|
||||
|
||||
/* Lines Tesseract reads on a page, page space, as JSON. */
|
||||
int ely_ocr_page(fz_context *ctx, fz_document *doc, int number, const char *lang, const char *datadir, float dpi,
|
||||
unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_page *page = NULL;
|
||||
fz_pixmap *pix = NULL;
|
||||
fz_image *image = NULL;
|
||||
fz_stext_page *text = NULL;
|
||||
fz_device *sdev = NULL, *ocr = NULL;
|
||||
fz_buffer *buf = NULL, *words = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(page);
|
||||
fz_var(pix);
|
||||
fz_var(image);
|
||||
fz_var(text);
|
||||
fz_var(sdev);
|
||||
fz_var(ocr);
|
||||
fz_var(buf);
|
||||
fz_var(words);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
float zoom = dpi / 72;
|
||||
fz_rect area;
|
||||
fz_stext_block *block;
|
||||
fz_stext_line *line;
|
||||
fz_stext_char *ch;
|
||||
int first = 1;
|
||||
if (dpi < 72 || dpi > 600)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a resolution of %g dpi", dpi);
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
pix = fz_new_pixmap_from_page(ctx, page, fz_scale(zoom, zoom), fz_device_gray(ctx), 0);
|
||||
image = fz_new_image_from_pixmap(ctx, pix, NULL);
|
||||
area = fz_make_rect(0, 0, (float)pix->w, (float)pix->h);
|
||||
text = fz_new_stext_page(ctx, area);
|
||||
sdev = fz_new_stext_device(ctx, text, NULL);
|
||||
ocr = fz_new_ocr_device(ctx, sdev, fz_identity, area, 0, lang, datadir, NULL, NULL);
|
||||
fz_fill_image(ctx, ocr, image, fz_make_matrix(area.x1, 0, 0, area.y1, 0, 0), 1, fz_default_color_params);
|
||||
fz_close_device(ctx, ocr);
|
||||
fz_close_device(ctx, sdev);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_byte(ctx, out, '[');
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
{
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
{
|
||||
fz_rect r = fz_transform_rect(line->bbox, fz_scale(1 / zoom, 1 / zoom));
|
||||
if (!line->first_char)
|
||||
continue;
|
||||
fz_drop_buffer(ctx, words);
|
||||
words = NULL;
|
||||
words = fz_new_buffer(ctx, 128);
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
fz_append_rune(ctx, words, ch->c);
|
||||
fz_terminate_buffer(ctx, words);
|
||||
fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":", first ? "" : ",", r.x0, r.y0, r.x1, r.y1);
|
||||
ely_json_string(ctx, out, fz_string_from_buffer(ctx, words));
|
||||
fz_write_byte(ctx, out, '}');
|
||||
first = 0;
|
||||
}
|
||||
}
|
||||
fz_write_byte(ctx, out, ']');
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_buffer(ctx, words);
|
||||
fz_drop_device(ctx, ocr);
|
||||
fz_drop_device(ctx, sdev);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_drop_image(ctx, image);
|
||||
fz_drop_pixmap(ctx, pix);
|
||||
fz_drop_page(ctx, page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A font with no glyphs; codes are UTF-16 units. */
|
||||
static pdf_obj *glyphless(fz_context *ctx, pdf_document *pdf)
|
||||
{
|
||||
pdf_obj *font = NULL, *cid = NULL, *desc = NULL, *info, *bbox, *kids;
|
||||
fz_buffer *cmap = NULL;
|
||||
fz_var(font);
|
||||
fz_var(cid);
|
||||
fz_var(desc);
|
||||
fz_var(cmap);
|
||||
fz_try(ctx)
|
||||
{
|
||||
desc = pdf_add_new_dict(ctx, pdf, 8);
|
||||
pdf_dict_put(ctx, desc, PDF_NAME(Type), PDF_NAME(FontDescriptor));
|
||||
pdf_dict_put_name(ctx, desc, PDF_NAME(FontName), "GlyphLessFont");
|
||||
pdf_dict_put_int(ctx, desc, PDF_NAME(Flags), 5);
|
||||
bbox = pdf_dict_put_array(ctx, desc, PDF_NAME(FontBBox), 4);
|
||||
pdf_array_push_int(ctx, bbox, 0), pdf_array_push_int(ctx, bbox, 0);
|
||||
pdf_array_push_int(ctx, bbox, 500), pdf_array_push_int(ctx, bbox, 1000);
|
||||
pdf_dict_put_int(ctx, desc, PDF_NAME(ItalicAngle), 0);
|
||||
pdf_dict_put_int(ctx, desc, PDF_NAME(Ascent), 1000);
|
||||
pdf_dict_put_int(ctx, desc, PDF_NAME(Descent), 0);
|
||||
pdf_dict_put_int(ctx, desc, PDF_NAME(CapHeight), 1000);
|
||||
pdf_dict_put_int(ctx, desc, PDF_NAME(StemV), 80);
|
||||
cid = pdf_add_new_dict(ctx, pdf, 8);
|
||||
pdf_dict_put(ctx, cid, PDF_NAME(Type), PDF_NAME(Font));
|
||||
pdf_dict_put(ctx, cid, PDF_NAME(Subtype), PDF_NAME(CIDFontType2));
|
||||
pdf_dict_put_name(ctx, cid, PDF_NAME(BaseFont), "GlyphLessFont");
|
||||
info = pdf_dict_put_dict(ctx, cid, PDF_NAME(CIDSystemInfo), 3);
|
||||
pdf_dict_put_text_string(ctx, info, PDF_NAME(Registry), "Adobe");
|
||||
pdf_dict_put_text_string(ctx, info, PDF_NAME(Ordering), "Identity");
|
||||
pdf_dict_put_int(ctx, info, PDF_NAME(Supplement), 0);
|
||||
pdf_dict_put(ctx, cid, PDF_NAME(FontDescriptor), desc);
|
||||
pdf_dict_put_int(ctx, cid, PDF_NAME(DW), 500);
|
||||
pdf_dict_put(ctx, cid, PDF_NAME(CIDToGIDMap), PDF_NAME(Identity));
|
||||
font = pdf_add_new_dict(ctx, pdf, 6);
|
||||
pdf_dict_put(ctx, font, PDF_NAME(Type), PDF_NAME(Font));
|
||||
pdf_dict_put(ctx, font, PDF_NAME(Subtype), PDF_NAME(Type0));
|
||||
pdf_dict_put_name(ctx, font, PDF_NAME(BaseFont), "GlyphLessFont");
|
||||
pdf_dict_put(ctx, font, PDF_NAME(Encoding), PDF_NAME(Identity_H));
|
||||
kids = pdf_dict_put_array(ctx, font, PDF_NAME(DescendantFonts), 1);
|
||||
pdf_array_push(ctx, kids, cid);
|
||||
cmap = fz_new_buffer_from_copied_data(ctx, (const unsigned char *)to_unicode, strlen(to_unicode));
|
||||
pdf_dict_put_drop(ctx, font, PDF_NAME(ToUnicode), pdf_add_stream(ctx, pdf, cmap, NULL, 0));
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, desc);
|
||||
pdf_drop_obj(ctx, cid);
|
||||
fz_drop_buffer(ctx, cmap);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, font);
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/* Drops the page's earlier Ely OCR layers. */
|
||||
static void drop_layers(fz_context *ctx, pdf_obj *page)
|
||||
{
|
||||
pdf_obj *contents = pdf_dict_get(ctx, page, PDF_NAME(Contents));
|
||||
int i;
|
||||
for (i = pdf_array_len(ctx, contents) - 1; i >= 0; i--)
|
||||
if (pdf_dict_gets(ctx, pdf_array_get(ctx, contents, i), "ElyOCR"))
|
||||
pdf_array_delete(ctx, contents, i);
|
||||
}
|
||||
|
||||
/* Throws unless every line is whole and in the BMP. */
|
||||
static void check_layer(fz_context *ctx, fz_json *pages, int count)
|
||||
{
|
||||
int i, k;
|
||||
for (i = 0; i < fz_json_array_length(ctx, pages); i++)
|
||||
{
|
||||
fz_json *spec = fz_json_array_get(ctx, pages, i);
|
||||
fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER);
|
||||
fz_json *lines = ely_member(ctx, spec, "lines", FZ_JSON_ARRAY);
|
||||
if (!page || page->u.number < 0 || page->u.number >= count || !lines)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a layer with no page or lines");
|
||||
for (k = 0; k < fz_json_array_length(ctx, lines); k++)
|
||||
{
|
||||
fz_json *line = fz_json_array_get(ctx, lines, k);
|
||||
fz_json *words = ely_member(ctx, line, "text", FZ_JSON_STRING);
|
||||
const char *s;
|
||||
int c;
|
||||
ely_rect(ctx, ely_member(ctx, line, "rect", FZ_JSON_ARRAY), 0.5f);
|
||||
if (!words || !words->u.string[0])
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a line with no text");
|
||||
for (s = words->u.string; *s; )
|
||||
{
|
||||
s += fz_chartorune(&c, s);
|
||||
if (c > 0xFFFF || c == FZ_REPLACEMENT_CHARACTER)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a character the layer cannot hold: U+%X", c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* One line: hidden text stretched over its box. */
|
||||
static void write_line(fz_context *ctx, fz_buffer *buf, fz_json *line, fz_matrix to_user, const char *font)
|
||||
{
|
||||
fz_rect r = ely_rect(ctx, ely_member(ctx, line, "rect", FZ_JSON_ARRAY), 0.5f);
|
||||
const char *s = ely_member(ctx, line, "text", FZ_JSON_STRING)->u.string, *p;
|
||||
float size = r.y1 - r.y0;
|
||||
int n = 0, c;
|
||||
fz_matrix m;
|
||||
for (p = s; *p; n++)
|
||||
p += fz_chartorune(&c, p);
|
||||
/* Each code is half an em wide. */
|
||||
m = fz_concat(fz_make_matrix((r.x1 - r.x0) / (0.5f * n), 0, 0, -size, r.x0, r.y1 - 0.2f * size), to_user);
|
||||
fz_append_printf(ctx, buf, "BT 3 Tr /%s 1 Tf %g %g %g %g %g %g Tm <", font, m.a, m.b, m.c, m.d, m.e, m.f);
|
||||
for (p = s; *p; )
|
||||
{
|
||||
p += fz_chartorune(&c, p);
|
||||
fz_append_printf(ctx, buf, "%04x", c);
|
||||
}
|
||||
fz_append_string(ctx, buf, "> Tj ET\n");
|
||||
}
|
||||
|
||||
/* Hidden text on pages, earlier layers first dropped if asked. */
|
||||
int ely_text_layer(fz_context *ctx, fz_document *doc, const char *json, int replace)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
pdf_obj *font = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(font);
|
||||
fz_var(buf);
|
||||
fz_var(pool);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *pages;
|
||||
int i, k;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
pool = fz_new_pool(ctx);
|
||||
pages = fz_parse_json(ctx, pool, json);
|
||||
if (pages->type != FZ_JSON_ARRAY || fz_json_array_length(ctx, pages) == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no pages to lay text on");
|
||||
check_layer(ctx, pages, pdf_count_pages(ctx, pdf));
|
||||
pdf_begin_operation(ctx, pdf, "Add recognised text");
|
||||
begun = 1;
|
||||
for (i = 0; i < fz_json_array_length(ctx, pages); i++)
|
||||
{
|
||||
fz_json *spec = fz_json_array_get(ctx, pages, i);
|
||||
fz_json *lines = ely_member(ctx, spec, "lines", FZ_JSON_ARRAY);
|
||||
char name[32];
|
||||
page = pdf_load_page(ctx, pdf, (int)ely_member(ctx, spec, "page", FZ_JSON_NUMBER)->u.number);
|
||||
if (replace)
|
||||
drop_layers(ctx, page->obj);
|
||||
if (fz_json_array_length(ctx, lines) > 0)
|
||||
{
|
||||
if (!font)
|
||||
font = glyphless(ctx, pdf);
|
||||
ely_add_resource(ctx, pdf, page->obj, PDF_NAME(Font), "ElyOCR", font, name, sizeof name);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
/* Its own state, so later text stays seen. */
|
||||
fz_append_string(ctx, buf, "q\n");
|
||||
for (k = 0; k < fz_json_array_length(ctx, lines); k++)
|
||||
write_line(ctx, buf, fz_json_array_get(ctx, lines, k), ely_to_user(ctx, page), name);
|
||||
fz_append_string(ctx, buf, "Q\n");
|
||||
pdf_dict_puts(ctx, ely_append_content(ctx, pdf, page->obj, buf), "ElyOCR", PDF_TRUE);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
buf = NULL;
|
||||
}
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
page = NULL;
|
||||
}
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
pdf_drop_obj(ctx, font);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_drop_pool(ctx, pool);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/* Bookmarks and links: change, list (PDF-07). */
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* A page point as a link target. */
|
||||
static char *target(fz_context *ctx, fz_document *doc, fz_json *spec)
|
||||
{
|
||||
fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER);
|
||||
fz_json *y = ely_member(ctx, spec, "y", FZ_JSON_NUMBER);
|
||||
if (!page || page->u.number < 0 || page->u.number >= fz_count_pages(ctx, doc))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a target with no page");
|
||||
return fz_format_link_uri(ctx, doc, fz_make_link_dest_xyz(0, (int)page->u.number, 0, y ? (float)y->u.number : 0, 0));
|
||||
}
|
||||
|
||||
/* Walks `path`; the last step may land past the end. */
|
||||
static int walk(fz_context *ctx, fz_outline_iterator *iter, fz_json *path)
|
||||
{
|
||||
int i, k, n = fz_json_array_length(ctx, path), at = FZ_OUTLINE_ITERATOR_AT_ITEM;
|
||||
if (n == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "an empty bookmark path");
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
int steps = (int)ely_number(ctx, path, i);
|
||||
if (i > 0)
|
||||
{
|
||||
at = fz_outline_iterator_down(ctx, iter);
|
||||
if (at < 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no bookmark at that path");
|
||||
}
|
||||
at = fz_outline_iterator_item(ctx, iter) ? FZ_OUTLINE_ITERATOR_AT_ITEM : FZ_OUTLINE_ITERATOR_AT_EMPTY;
|
||||
for (k = 0; k < steps; k++)
|
||||
if (at != FZ_OUTLINE_ITERATOR_AT_ITEM || (at = fz_outline_iterator_next(ctx, iter)) < 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no bookmark at that path");
|
||||
if (i < n - 1 && at != FZ_OUTLINE_ITERATOR_AT_ITEM)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no bookmark at that path");
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
/* Adds, renames, retargets or deletes one bookmark, in one step. */
|
||||
int ely_outline_edit(fz_context *ctx, fz_document *doc, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
fz_outline_iterator *iter = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
char *uri = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(iter);
|
||||
fz_var(pool);
|
||||
fz_var(uri);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *spec, *op, *path, *title, *expect;
|
||||
fz_outline_item *now, item = { 0 };
|
||||
int at;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
pool = fz_new_pool(ctx);
|
||||
spec = fz_parse_json(ctx, pool, json);
|
||||
op = ely_member(ctx, spec, "op", FZ_JSON_STRING);
|
||||
path = ely_member(ctx, spec, "path", FZ_JSON_ARRAY);
|
||||
title = ely_member(ctx, spec, "title", FZ_JSON_STRING);
|
||||
expect = ely_member(ctx, spec, "expect", FZ_JSON_STRING);
|
||||
if (!op || !path || (strcmp(op->u.string, "add") && strcmp(op->u.string, "edit") && strcmp(op->u.string, "delete")))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad bookmark change");
|
||||
if (title && !title->u.string[0])
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bookmark with no title");
|
||||
if (!strcmp(op->u.string, "add") && !title)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a new bookmark with no title");
|
||||
if (strcmp(op->u.string, "add") && !expect)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a change that does not name its bookmark");
|
||||
if (ely_member(ctx, spec, "page", FZ_JSON_NUMBER))
|
||||
uri = target(ctx, doc, spec);
|
||||
else if (!strcmp(op->u.string, "add"))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a new bookmark with no target");
|
||||
pdf_begin_operation(ctx, pdf, "Change bookmarks");
|
||||
begun = 1;
|
||||
iter = fz_new_outline_iterator(ctx, doc);
|
||||
at = walk(ctx, iter, path);
|
||||
if (!strcmp(op->u.string, "add"))
|
||||
{
|
||||
item.title = (char *)title->u.string;
|
||||
item.uri = uri;
|
||||
fz_outline_iterator_insert(ctx, iter, &item);
|
||||
}
|
||||
else
|
||||
{
|
||||
now = at == FZ_OUTLINE_ITERATOR_AT_ITEM ? fz_outline_iterator_item(ctx, iter) : NULL;
|
||||
if (!now || strcmp(now->title ? now->title : "", expect->u.string))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the bookmark changed meanwhile");
|
||||
if (!strcmp(op->u.string, "delete"))
|
||||
fz_outline_iterator_delete(ctx, iter);
|
||||
else
|
||||
{
|
||||
item = *now;
|
||||
if (title)
|
||||
item.title = (char *)title->u.string;
|
||||
if (uri)
|
||||
item.uri = uri;
|
||||
fz_outline_iterator_update(ctx, iter, &item);
|
||||
}
|
||||
}
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, uri);
|
||||
fz_drop_outline_iterator(ctx, iter);
|
||||
fz_drop_pool(ctx, pool);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The page's links: box, address, and page if inside. */
|
||||
int ely_links(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_page *page = NULL;
|
||||
fz_link *links = NULL, *link;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(page);
|
||||
fz_var(links);
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
links = fz_load_links(ctx, page);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_byte(ctx, out, '[');
|
||||
for (link = links; link; link = link->next)
|
||||
{
|
||||
int to = -1;
|
||||
float x = 0, y = 0;
|
||||
if (!fz_is_external_link(ctx, link->uri))
|
||||
to = fz_page_number_from_location(ctx, doc, fz_resolve_link(ctx, doc, link->uri, &x, &y));
|
||||
fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"uri\":", link == links ? "" : ",",
|
||||
link->rect.x0, link->rect.y0, link->rect.x1, link->rect.y1);
|
||||
ely_json_string(ctx, out, link->uri ? link->uri : "");
|
||||
fz_write_printf(ctx, out, ",\"page\":%d,\"y\":%g}", to, y);
|
||||
}
|
||||
fz_write_byte(ctx, out, ']');
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_link(ctx, links);
|
||||
fz_drop_page(ctx, page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Only web and mail addresses leave the document. */
|
||||
static int outward(const char *uri)
|
||||
{
|
||||
return !strncmp(uri, "https://", 8) || !strncmp(uri, "http://", 7) || !strncmp(uri, "mailto:", 7);
|
||||
}
|
||||
|
||||
/* Adds, retargets or deletes one link, in one step. */
|
||||
int ely_link_edit(fz_context *ctx, fz_document *doc, int number, const char *json)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_pool *pool = NULL;
|
||||
char *uri = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(pool);
|
||||
fz_var(uri);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_json *spec, *op, *index, *expect, *web;
|
||||
fz_link *link = NULL;
|
||||
fz_rect box = fz_empty_rect;
|
||||
int i;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
pool = fz_new_pool(ctx);
|
||||
spec = fz_parse_json(ctx, pool, json);
|
||||
op = ely_member(ctx, spec, "op", FZ_JSON_STRING);
|
||||
index = ely_member(ctx, spec, "index", FZ_JSON_NUMBER);
|
||||
expect = ely_member(ctx, spec, "expect", FZ_JSON_STRING);
|
||||
web = ely_member(ctx, spec, "uri", FZ_JSON_STRING);
|
||||
if (!op || (strcmp(op->u.string, "add") && strcmp(op->u.string, "edit") && strcmp(op->u.string, "delete")))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a bad link change");
|
||||
if (web && !outward(web->u.string))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "only web and mail addresses: %s", web->u.string);
|
||||
if (web)
|
||||
uri = fz_strdup(ctx, web->u.string);
|
||||
else if (ely_member(ctx, spec, "page", FZ_JSON_NUMBER))
|
||||
uri = target(ctx, doc, spec);
|
||||
else if (strcmp(op->u.string, "delete"))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a link with no target");
|
||||
if (strcmp(op->u.string, "add"))
|
||||
{
|
||||
if (!index || !expect)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a change that does not name its link");
|
||||
for (link = page->links, i = 0; link && i < (int)index->u.number; link = link->next)
|
||||
i++;
|
||||
if (!link || strcmp(link->uri ? link->uri : "", expect->u.string))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the link changed meanwhile");
|
||||
}
|
||||
if (!strcmp(op->u.string, "add"))
|
||||
box = ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 2);
|
||||
pdf_begin_operation(ctx, pdf, "Change links");
|
||||
begun = 1;
|
||||
if (!strcmp(op->u.string, "add"))
|
||||
fz_drop_link(ctx, pdf_create_link(ctx, page, box, uri));
|
||||
else if (!strcmp(op->u.string, "delete"))
|
||||
pdf_delete_link(ctx, page, link);
|
||||
else
|
||||
fz_set_link_uri(ctx, link, uri);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, uri);
|
||||
fz_drop_pool(ctx, pool);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Page images, text, optimized copies, repaired copies (PDF-13, PDF-14). */
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* Page `number` as a PNG at `dpi`, white, as printed. */
|
||||
int ely_export_image(fz_context *ctx, fz_document *doc, int number, float dpi, const char *path)
|
||||
{
|
||||
fz_pixmap *pixmap = NULL;
|
||||
fz_var(pixmap);
|
||||
fz_try(ctx)
|
||||
{
|
||||
if (dpi < 18 || dpi > 1200)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a resolution outside 18 to 1200 dpi");
|
||||
ely_watch_begin(ctx);
|
||||
pixmap = fz_new_pixmap_from_page_number(ctx, doc, number, fz_scale(dpi / 72, dpi / 72), fz_device_rgb(ctx), 0);
|
||||
if (ely_watch_end(ctx))
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "page %d did not read whole: %s", number + 1, ely_open_warnings());
|
||||
fz_save_pixmap_as_png(ctx, pixmap, path);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
ely_watch_end(ctx);
|
||||
fz_drop_pixmap(ctx, pixmap);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Every page's text, UTF-8, pages parted by form feeds. */
|
||||
int ely_export_text(fz_context *ctx, fz_document *doc, const char *path)
|
||||
{
|
||||
fz_output *out = NULL;
|
||||
fz_stext_page *text = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(out);
|
||||
fz_var(text);
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
int i;
|
||||
ely_watch_begin(ctx);
|
||||
out = fz_new_output_with_path(ctx, path, 0);
|
||||
for (i = 0; i < fz_count_pages(ctx, doc); i++)
|
||||
{
|
||||
if (i > 0)
|
||||
fz_write_byte(ctx, out, '\f');
|
||||
text = fz_new_stext_page_from_page_number(ctx, doc, i, NULL);
|
||||
buf = fz_new_buffer_from_stext_page(ctx, text);
|
||||
fz_write_buffer(ctx, out, buf);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
buf = NULL;
|
||||
fz_drop_stext_page(ctx, text);
|
||||
text = NULL;
|
||||
}
|
||||
/* Partial text is no export. */
|
||||
if (ely_watch_end(ctx))
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "the text did not read whole: %s", ely_open_warnings());
|
||||
fz_close_output(ctx, out);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
ely_watch_end(ctx);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_drop_output(ctx, out);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
typedef struct
|
||||
{
|
||||
int from, to;
|
||||
} downsample;
|
||||
|
||||
static void shrink_images(fz_context *ctx, fz_document *copy, void *arg)
|
||||
{
|
||||
downsample *d = arg;
|
||||
pdf_image_rewriter_options opts = { 0 };
|
||||
if (!d->to)
|
||||
return;
|
||||
opts.color_lossless_image_subsample_method = opts.color_lossy_image_subsample_method = FZ_SUBSAMPLE_BICUBIC;
|
||||
opts.gray_lossless_image_subsample_method = opts.gray_lossy_image_subsample_method = FZ_SUBSAMPLE_BICUBIC;
|
||||
opts.bitonal_image_subsample_method = FZ_SUBSAMPLE_BICUBIC;
|
||||
opts.color_lossless_image_subsample_threshold = opts.color_lossy_image_subsample_threshold = d->from;
|
||||
opts.gray_lossless_image_subsample_threshold = opts.gray_lossy_image_subsample_threshold = d->from;
|
||||
opts.bitonal_image_subsample_threshold = d->from;
|
||||
opts.color_lossless_image_subsample_to = opts.color_lossy_image_subsample_to = d->to;
|
||||
opts.gray_lossless_image_subsample_to = opts.gray_lossy_image_subsample_to = d->to;
|
||||
opts.bitonal_image_subsample_to = d->to;
|
||||
/* Never, the default, drops the smaller picture. */
|
||||
opts.color_lossless_image_recompress_method = opts.gray_lossless_image_recompress_method = FZ_RECOMPRESS_LOSSLESS;
|
||||
opts.color_lossy_image_recompress_method = opts.gray_lossy_image_recompress_method = FZ_RECOMPRESS_SAME;
|
||||
opts.bitonal_image_recompress_method = FZ_RECOMPRESS_SAME;
|
||||
pdf_rewrite_images(ctx, ely_pdf_of(ctx, copy), &opts);
|
||||
}
|
||||
|
||||
/* Writes `out` compressed, images above `from` dpi taken to `to`. */
|
||||
int ely_optimize(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password, int compress,
|
||||
int from, int to)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_write_options opts = pdf_default_write_options;
|
||||
downsample d = { from, to };
|
||||
if (to && (to < 36 || from <= to))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a downsampling from %d to %d dpi", from, to);
|
||||
opts.do_compress = opts.do_compress_images = opts.do_compress_fonts = compress;
|
||||
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, shrink_images, &d);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 1 when MuPDF rebuilt the file's structure to read it. */
|
||||
int ely_repaired(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
int repaired = 0;
|
||||
fz_try(ctx)
|
||||
repaired = pdf_was_repaired(ctx, ely_pdf_of(ctx, doc));
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/* The repaired document written whole to `path`. */
|
||||
int ely_write_repaired(fz_context *ctx, fz_document *doc, const char *path)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_write_options opts = pdf_default_write_options;
|
||||
opts.do_garbage = 1;
|
||||
pdf_save_document(ctx, ely_pdf_of(ctx, doc), path, &opts);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Leave to print was checked; the printer gets no lock. */
|
||||
int ely_print_copy(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_write_options opts = pdf_default_write_options;
|
||||
opts.do_encrypt = PDF_ENCRYPT_NONE;
|
||||
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, NULL, NULL);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Decodes every picture; 1 if MuPDF warned on any. */
|
||||
int ely_check_pictures(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
int i;
|
||||
ely_watch_begin(ctx);
|
||||
for (i = 1; i < pdf_xref_len(ctx, pdf); i++)
|
||||
{
|
||||
pdf_obj *obj = NULL;
|
||||
fz_buffer *bytes = NULL;
|
||||
fz_var(obj);
|
||||
fz_var(bytes);
|
||||
fz_try(ctx)
|
||||
{
|
||||
obj = pdf_new_indirect(ctx, pdf, i, 0);
|
||||
if (pdf_is_stream(ctx, obj) && pdf_name_eq(ctx, pdf_dict_get(ctx, obj, PDF_NAME(Subtype)), PDF_NAME(Image)))
|
||||
bytes = pdf_load_stream_number(ctx, pdf, i);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, bytes);
|
||||
pdf_drop_obj(ctx, obj);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
{
|
||||
fz_warn(ctx, "picture %d: %s", i, fz_caught_message(ctx));
|
||||
fz_ignore_error(ctx);
|
||||
}
|
||||
}
|
||||
return ely_watch_end(ctx);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/* Page edits, undo and saving; one undo step each. */
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
pdf_document *ely_pdf_of(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
pdf_document *pdf = pdf_specifics(ctx, doc);
|
||||
if (!pdf)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "not a PDF");
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/* MuPDF's font and image caches outlive an undo; they go. */
|
||||
void ely_forget_resources(fz_context *ctx, pdf_document *pdf)
|
||||
{
|
||||
pdf_drop_resource_tables(ctx, pdf);
|
||||
pdf->resources.fonts = NULL;
|
||||
pdf->resources.colorspaces = NULL;
|
||||
pdf->resources.images = NULL;
|
||||
}
|
||||
|
||||
/* Reports the failure; a begun step is rolled back. */
|
||||
int ely_edit_failed(fz_context *ctx, pdf_document *pdf, int begun)
|
||||
{
|
||||
int code = ely_fail(ctx);
|
||||
if (begun)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_abandon_operation(ctx, pdf);
|
||||
ely_forget_resources(ctx, pdf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
ely_fail_also(ctx, "rolling back");
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
int ely_journal(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
fz_try(ctx)
|
||||
pdf_enable_journal(ctx, ely_pdf_of(ctx, doc));
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ely_undo(fz_context *ctx, fz_document *doc, int redo)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
if (redo)
|
||||
pdf_redo(ctx, pdf);
|
||||
else
|
||||
pdf_undo(ctx, pdf);
|
||||
ely_forget_resources(ctx, pdf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* MuPDF's undo position and step count; saves add steps. */
|
||||
int ely_state(fz_context *ctx, fz_document *doc, int *at, int *count)
|
||||
{
|
||||
fz_try(ctx)
|
||||
*at = pdf_undoredo_state(ctx, ely_pdf_of(ctx, doc), count);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int by_number(const void *a, const void *b)
|
||||
{
|
||||
return *(const int *)a - *(const int *)b;
|
||||
}
|
||||
|
||||
/* Object numbers, sorted for lookups. */
|
||||
typedef struct
|
||||
{
|
||||
int *nums;
|
||||
int n;
|
||||
} gone_set;
|
||||
|
||||
static int is_gone(fz_context *ctx, gone_set *gone, pdf_obj *obj)
|
||||
{
|
||||
int num = pdf_to_num(ctx, obj);
|
||||
return num && bsearch(&num, gone->nums, gone->n, sizeof(int), by_number) != NULL;
|
||||
}
|
||||
|
||||
/* Deleted pages, and the annotations they held. */
|
||||
typedef struct
|
||||
{
|
||||
gone_set pages;
|
||||
gone_set annots;
|
||||
} gone_pages;
|
||||
|
||||
/* The page a link, bookmark or action points to. */
|
||||
pdf_obj *ely_target(fz_context *ctx, pdf_document *pdf, pdf_obj *obj)
|
||||
{
|
||||
pdf_obj *dest = pdf_dict_get(ctx, obj, PDF_NAME(Dest));
|
||||
if (!dest)
|
||||
{
|
||||
pdf_obj *action = pdf_dict_get(ctx, obj, PDF_NAME(A));
|
||||
if (!pdf_name_eq(ctx, pdf_dict_get(ctx, action, PDF_NAME(S)), PDF_NAME(GoTo)))
|
||||
return NULL;
|
||||
dest = pdf_dict_get(ctx, action, PDF_NAME(D));
|
||||
}
|
||||
if (pdf_is_name(ctx, dest) || pdf_is_string(ctx, dest))
|
||||
dest = pdf_lookup_dest(ctx, pdf, dest);
|
||||
if (pdf_is_dict(ctx, dest))
|
||||
dest = pdf_dict_get(ctx, dest, PDF_NAME(D));
|
||||
return pdf_is_array(ctx, dest) ? pdf_array_get(ctx, dest, 0) : NULL;
|
||||
}
|
||||
|
||||
/* Bookmarks to gone pages keep their place, lose the jump. */
|
||||
static void forget_in_outline(fz_context *ctx, pdf_document *pdf, pdf_obj *item, gone_set *gone)
|
||||
{
|
||||
for (; item; item = pdf_dict_get(ctx, item, PDF_NAME(Next)))
|
||||
{
|
||||
pdf_obj *page = ely_target(ctx, pdf, item);
|
||||
if (page && is_gone(ctx, gone, page))
|
||||
{
|
||||
pdf_dict_del(ctx, item, PDF_NAME(Dest));
|
||||
pdf_dict_del(ctx, item, PDF_NAME(A));
|
||||
}
|
||||
forget_in_outline(ctx, pdf, pdf_dict_get(ctx, item, PDF_NAME(First)), gone);
|
||||
}
|
||||
}
|
||||
|
||||
/* Whether a field keeps a widget on a kept page. */
|
||||
static int prune_field(fz_context *ctx, pdf_obj *field, gone_pages *gone)
|
||||
{
|
||||
pdf_obj *kids = pdf_dict_get(ctx, field, PDF_NAME(Kids));
|
||||
int i;
|
||||
if (!kids)
|
||||
{
|
||||
/* A deleted page held it, or its /P names one. */
|
||||
pdf_obj *page = pdf_dict_get(ctx, field, PDF_NAME(P));
|
||||
if (!pdf_name_eq(ctx, pdf_dict_get(ctx, field, PDF_NAME(Subtype)), PDF_NAME(Widget)))
|
||||
return 1;
|
||||
return !is_gone(ctx, &gone->annots, field) && !(page && is_gone(ctx, &gone->pages, page));
|
||||
}
|
||||
for (i = pdf_array_len(ctx, kids) - 1; i >= 0; i--)
|
||||
if (!prune_field(ctx, pdf_array_get(ctx, kids, i), gone))
|
||||
pdf_array_delete(ctx, kids, i);
|
||||
return pdf_array_len(ctx, kids) > 0;
|
||||
}
|
||||
|
||||
/* Drops what pointed into deleted pages. */
|
||||
static void forget_pages(fz_context *ctx, pdf_document *pdf, gone_pages *gone)
|
||||
{
|
||||
pdf_obj *root = pdf_dict_get(ctx, pdf_trailer(ctx, pdf), PDF_NAME(Root));
|
||||
pdf_obj *outlines = pdf_dict_get(ctx, root, PDF_NAME(Outlines));
|
||||
pdf_obj *fields = pdf_dict_getp(ctx, root, "AcroForm/Fields");
|
||||
int i, j, count = pdf_count_pages(ctx, pdf);
|
||||
forget_in_outline(ctx, pdf, pdf_dict_get(ctx, outlines, PDF_NAME(First)), &gone->pages);
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, pdf, i), PDF_NAME(Annots));
|
||||
for (j = pdf_array_len(ctx, annots) - 1; j >= 0; j--)
|
||||
{
|
||||
pdf_obj *annot = pdf_array_get(ctx, annots, j);
|
||||
pdf_obj *page;
|
||||
if (!pdf_name_eq(ctx, pdf_dict_get(ctx, annot, PDF_NAME(Subtype)), PDF_NAME(Link)))
|
||||
continue;
|
||||
page = ely_target(ctx, pdf, annot);
|
||||
if (page && is_gone(ctx, &gone->pages, page))
|
||||
pdf_array_delete(ctx, annots, j);
|
||||
}
|
||||
}
|
||||
for (i = pdf_array_len(ctx, fields) - 1; i >= 0; i--)
|
||||
if (!prune_field(ctx, pdf_array_get(ctx, fields, i), gone))
|
||||
pdf_array_delete(ctx, fields, i);
|
||||
}
|
||||
|
||||
/* Checks `pages` are sorted page numbers without repeats. */
|
||||
static void check_pages(fz_context *ctx, pdf_document *pdf, const int *pages, int n)
|
||||
{
|
||||
int i, count = pdf_count_pages(ctx, pdf);
|
||||
if (n <= 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no pages given");
|
||||
for (i = 0; i < n; i++)
|
||||
if (pages[i] < 0 || pages[i] >= count || (i > 0 && pages[i] <= pages[i - 1]))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "page %d is out of order or range", pages[i]);
|
||||
}
|
||||
|
||||
/* Deletes checked `pages` and what pointed there. */
|
||||
static void delete_pages(fz_context *ctx, pdf_document *pdf, const int *pages, int n)
|
||||
{
|
||||
gone_pages gone = { { NULL, n }, { NULL, 0 } };
|
||||
int i, j, held = 0;
|
||||
fz_var(gone.pages.nums);
|
||||
fz_var(gone.annots.nums);
|
||||
fz_try(ctx)
|
||||
{
|
||||
gone.pages.nums = fz_malloc_array(ctx, n, int);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, pages[i]);
|
||||
gone.pages.nums[i] = pdf_to_num(ctx, page);
|
||||
held += pdf_array_len(ctx, pdf_dict_get(ctx, page, PDF_NAME(Annots)));
|
||||
}
|
||||
gone.annots.nums = fz_malloc_array(ctx, held + 1, int);
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, pdf, pages[i]), PDF_NAME(Annots));
|
||||
for (j = 0; j < pdf_array_len(ctx, annots); j++)
|
||||
gone.annots.nums[gone.annots.n++] = pdf_to_num(ctx, pdf_array_get(ctx, annots, j));
|
||||
}
|
||||
qsort(gone.pages.nums, n, sizeof(int), by_number);
|
||||
qsort(gone.annots.nums, gone.annots.n, sizeof(int), by_number);
|
||||
for (i = n - 1; i >= 0; i--)
|
||||
pdf_delete_page(ctx, pdf, pages[i]);
|
||||
forget_pages(ctx, pdf, &gone);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, gone.pages.nums);
|
||||
fz_free(ctx, gone.annots.nums);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
int ely_delete_pages(fz_context *ctx, fz_document *doc, const int *pages, int n)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
check_pages(ctx, pdf, pages, n);
|
||||
if (n == pdf_count_pages(ctx, pdf))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a PDF keeps at least one page");
|
||||
pdf_begin_operation(ctx, pdf, "Delete pages");
|
||||
begun = 1;
|
||||
delete_pages(ctx, pdf, pages, n);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Moves `pages` before page `to`, in their order. */
|
||||
int ely_move_pages(fz_context *ctx, fz_document *doc, const int *pages, int n, int to)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_obj **moved = NULL;
|
||||
int begun = 0, i, at;
|
||||
fz_var(pdf);
|
||||
fz_var(begun);
|
||||
fz_var(moved);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
check_pages(ctx, pdf, pages, n);
|
||||
if (to < 0 || to > pdf_count_pages(ctx, pdf))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no place %d to move to", to);
|
||||
moved = fz_calloc(ctx, n, sizeof *moved);
|
||||
for (i = 0; i < n; i++)
|
||||
moved[i] = pdf_keep_obj(ctx, pdf_lookup_page_obj(ctx, pdf, pages[i]));
|
||||
pdf_begin_operation(ctx, pdf, "Move pages");
|
||||
begun = 1;
|
||||
for (i = n - 1; i >= 0; i--)
|
||||
pdf_delete_page(ctx, pdf, pages[i]);
|
||||
for (at = to, i = 0; i < n; i++)
|
||||
at -= pages[i] < to;
|
||||
for (i = 0; i < n; i++)
|
||||
pdf_insert_page(ctx, pdf, at + i, moved[i]);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
for (i = 0; moved && i < n; i++)
|
||||
pdf_drop_obj(ctx, moved[i]);
|
||||
fz_free(ctx, moved);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Turns `pages` by a multiple of 90 degrees. */
|
||||
int ely_rotate_pages(fz_context *ctx, fz_document *doc, const int *pages, int n, int by)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
int begun = 0, i;
|
||||
fz_var(pdf);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
check_pages(ctx, pdf, pages, n);
|
||||
if (by % 90)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a turn of %d degrees", by);
|
||||
pdf_begin_operation(ctx, pdf, "Rotate pages");
|
||||
begun = 1;
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, pages[i]);
|
||||
int now = pdf_to_int(ctx, pdf_dict_get_inheritable(ctx, page, PDF_NAME(Rotate)));
|
||||
pdf_dict_put_int(ctx, page, PDF_NAME(Rotate), ((now + by) % 360 + 360) % 360);
|
||||
}
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Seen margins (top, right, bottom, left) to user sides. */
|
||||
static fz_rect inset(fz_rect box, int rotate, const float seen[4])
|
||||
{
|
||||
/* User sides: 0 y1, 1 x1, 2 y0, 3 x0. */
|
||||
static const int under[4][4] = { { 0, 1, 2, 3 }, { 3, 0, 1, 2 }, { 2, 3, 0, 1 }, { 1, 2, 3, 0 } };
|
||||
float side[4] = { 0, 0, 0, 0 };
|
||||
int i, turn = ((rotate % 360 + 360) % 360) / 90;
|
||||
for (i = 0; i < 4; i++)
|
||||
side[under[turn][i]] = seen[i];
|
||||
box.y1 -= side[0];
|
||||
box.x1 -= side[1];
|
||||
box.y0 += side[2];
|
||||
box.x0 += side[3];
|
||||
return box;
|
||||
}
|
||||
|
||||
/* Crops `pages` by seen margins in points; zeros uncrop. */
|
||||
int ely_crop_pages(fz_context *ctx, fz_document *doc, const int *pages, int n, const float margins[4])
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
int begun = 0, i;
|
||||
fz_var(pdf);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
int none = margins[0] == 0 && margins[1] == 0 && margins[2] == 0 && margins[3] == 0;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
check_pages(ctx, pdf, pages, n);
|
||||
pdf_begin_operation(ctx, pdf, "Crop pages");
|
||||
begun = 1;
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
pdf_obj *page = pdf_lookup_page_obj(ctx, pdf, pages[i]);
|
||||
fz_rect media = pdf_to_rect(ctx, pdf_dict_get_inheritable(ctx, page, PDF_NAME(MediaBox)));
|
||||
int rotate = pdf_to_int(ctx, pdf_dict_get_inheritable(ctx, page, PDF_NAME(Rotate)));
|
||||
/* Points as shown; user units may be larger. */
|
||||
float unit = pdf_to_real(ctx, pdf_dict_get(ctx, page, PDF_NAME(UserUnit)));
|
||||
float user[4];
|
||||
int side;
|
||||
for (side = 0; side < 4; side++)
|
||||
user[side] = margins[side] / (unit > 0 ? unit : 1);
|
||||
fz_rect crop = inset(media, rotate, user);
|
||||
if (none)
|
||||
{
|
||||
pdf_dict_del(ctx, page, PDF_NAME(CropBox));
|
||||
continue;
|
||||
}
|
||||
if (crop.x1 - crop.x0 < 1 || crop.y1 - crop.y0 < 1)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the margins leave nothing of page %d", pages[i] + 1);
|
||||
pdf_dict_put_rect(ctx, page, PDF_NAME(CropBox), crop);
|
||||
}
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A blank page of w by h points at `at`. */
|
||||
int ely_insert_blank(fz_context *ctx, fz_document *doc, int at, float w, float h)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_obj *page = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
if (at < 0 || at > pdf_count_pages(ctx, pdf) || w < 1 || h < 1)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a blank page at %d of %g by %g", at, w, h);
|
||||
pdf_begin_operation(ctx, pdf, "Insert a blank page");
|
||||
begun = 1;
|
||||
page = pdf_add_page(ctx, pdf, fz_make_rect(0, 0, w, h), 0, NULL, NULL);
|
||||
pdf_insert_page(ctx, pdf, at, page);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_drop_obj(ctx, page);
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Pages of `pdf` other than the sorted `keep`. */
|
||||
static int *others(fz_context *ctx, pdf_document *pdf, const int *keep, int n, int *k)
|
||||
{
|
||||
int i, count = pdf_count_pages(ctx, pdf);
|
||||
int *rest = fz_malloc_array(ctx, count + 1, int);
|
||||
*k = 0;
|
||||
for (i = 0; i < count; i++)
|
||||
if (bsearch(&i, keep, n, sizeof(int), by_number) == NULL)
|
||||
rest[(*k)++] = i;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/* A snapshot, reopened, changed, unsigned, compacted to `out`. */
|
||||
void ely_save_copy(fz_context *ctx, pdf_document *pdf, const char *raw, const char *out, const char *password,
|
||||
const pdf_write_options *opts, ely_change *change, void *arg)
|
||||
{
|
||||
fz_document *copy = NULL;
|
||||
fz_var(copy);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_write_options compact = opts ? *opts : pdf_default_write_options;
|
||||
pdf_save_snapshot(ctx, pdf, raw);
|
||||
copy = fz_open_document(ctx, raw);
|
||||
if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the written copy refused its password");
|
||||
if (change)
|
||||
change(ctx, copy, arg);
|
||||
ely_unsign(ctx, ely_pdf_of(ctx, copy));
|
||||
/* Unused objects, such as deleted pages, go. */
|
||||
compact.do_garbage = 2;
|
||||
pdf_save_document(ctx, ely_pdf_of(ctx, copy), out, &compact);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_document(ctx, copy);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
typedef struct
|
||||
{
|
||||
const int *pages;
|
||||
int n;
|
||||
} kept;
|
||||
|
||||
/* Deletes every page not kept. */
|
||||
static void keep_only(fz_context *ctx, fz_document *copy, void *arg)
|
||||
{
|
||||
kept *keep = arg;
|
||||
pdf_document *written = ely_pdf_of(ctx, copy);
|
||||
int k, *rest = others(ctx, written, keep->pages, keep->n, &k);
|
||||
fz_try(ctx)
|
||||
{
|
||||
if (k > 0)
|
||||
delete_pages(ctx, written, rest, k);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, rest);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
int ely_save(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password)
|
||||
{
|
||||
fz_try(ctx)
|
||||
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, NULL, NULL, NULL);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Saves only `pages` to `out`; the document is untouched. */
|
||||
int ely_extract(fz_context *ctx, fz_document *doc, const int *pages, int n,
|
||||
const char *raw, const char *out, const char *password)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
kept keep = { pages, n };
|
||||
check_pages(ctx, pdf, pages, n);
|
||||
ely_save_copy(ctx, pdf, raw, out, password, NULL, keep_only, &keep);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/* Images and form XObjects: list, move, scale, swap, add (PDF-04). */
|
||||
|
||||
#include <math.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* One drawn object: its kind and where it lands. */
|
||||
typedef struct
|
||||
{
|
||||
int form;
|
||||
fz_matrix ctm;
|
||||
fz_rect rect;
|
||||
} drawn;
|
||||
|
||||
/* Counts top-level draws, following q, Q and cm. */
|
||||
typedef struct
|
||||
{
|
||||
pdf_processor super;
|
||||
fz_matrix ctm;
|
||||
fz_matrix *stack;
|
||||
int depth, room;
|
||||
fz_matrix page;
|
||||
drawn *found;
|
||||
int n, cap;
|
||||
} inventory;
|
||||
|
||||
static void inv_q(fz_context *ctx, pdf_processor *proc)
|
||||
{
|
||||
inventory *inv = (inventory *)proc;
|
||||
if (inv->depth == inv->room)
|
||||
{
|
||||
inv->room = inv->room * 2 + 8;
|
||||
inv->stack = fz_realloc_array(ctx, inv->stack, inv->room, fz_matrix);
|
||||
}
|
||||
inv->stack[inv->depth++] = inv->ctm;
|
||||
}
|
||||
|
||||
static void inv_Q(fz_context *ctx, pdf_processor *proc)
|
||||
{
|
||||
inventory *inv = (inventory *)proc;
|
||||
if (inv->depth > 0)
|
||||
inv->ctm = inv->stack[--inv->depth];
|
||||
}
|
||||
|
||||
static void inv_cm(fz_context *ctx, pdf_processor *proc, float a, float b, float c, float d, float e, float f)
|
||||
{
|
||||
inventory *inv = (inventory *)proc;
|
||||
inv->ctm = fz_concat(fz_make_matrix(a, b, c, d, e, f), inv->ctm);
|
||||
}
|
||||
|
||||
static void found(fz_context *ctx, inventory *inv, int form, fz_rect box)
|
||||
{
|
||||
if (inv->n == inv->cap)
|
||||
{
|
||||
inv->cap = inv->cap * 2 + 8;
|
||||
inv->found = fz_realloc_array(ctx, inv->found, inv->cap, drawn);
|
||||
}
|
||||
inv->found[inv->n].form = form;
|
||||
inv->found[inv->n].ctm = inv->ctm;
|
||||
inv->found[inv->n++].rect = fz_transform_rect(box, fz_concat(inv->ctm, inv->page));
|
||||
}
|
||||
|
||||
static void inv_image(fz_context *ctx, pdf_processor *proc, const char *name, fz_image *image)
|
||||
{
|
||||
found(ctx, (inventory *)proc, 0, fz_unit_rect);
|
||||
}
|
||||
|
||||
static void inv_form(fz_context *ctx, pdf_processor *proc, const char *name, pdf_obj *form)
|
||||
{
|
||||
fz_rect box = fz_transform_rect(pdf_xobject_bbox(ctx, form), pdf_xobject_matrix(ctx, form));
|
||||
found(ctx, (inventory *)proc, 1, box);
|
||||
}
|
||||
|
||||
/* The page's top-level draws, in content order. */
|
||||
static int take_inventory(fz_context *ctx, pdf_document *pdf, pdf_page *page, drawn **out)
|
||||
{
|
||||
inventory *inv = pdf_new_processor(ctx, sizeof *inv);
|
||||
int n = 0;
|
||||
fz_try(ctx)
|
||||
{
|
||||
inv->super.op_q = inv_q;
|
||||
inv->super.op_Q = inv_Q;
|
||||
inv->super.op_cm = inv_cm;
|
||||
inv->super.op_Do_image = inv_image;
|
||||
inv->super.op_Do_form = inv_form;
|
||||
inv->ctm = fz_identity;
|
||||
pdf_page_transform(ctx, page, NULL, &inv->page);
|
||||
pdf_process_contents(ctx, &inv->super, pdf, pdf_page_resources(ctx, page), pdf_page_contents(ctx, page), NULL, NULL);
|
||||
pdf_close_processor(ctx, &inv->super);
|
||||
*out = inv->found;
|
||||
n = inv->n;
|
||||
inv->found = NULL;
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, inv->stack);
|
||||
fz_free(ctx, inv->found);
|
||||
pdf_drop_processor(ctx, &inv->super);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* JSON of the page's images and forms. */
|
||||
int ely_objects(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len)
|
||||
{
|
||||
pdf_page *page = NULL;
|
||||
drawn *list = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(page);
|
||||
fz_var(list);
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
int i, n;
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
n = take_inventory(ctx, pdf, page, &list);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
fz_append_byte(ctx, buf, '[');
|
||||
for (i = 0; i < n; i++)
|
||||
fz_append_printf(ctx, buf, "%s{\"kind\":\"%s\",\"rect\":[%g,%g,%g,%g]}", i ? "," : "",
|
||||
list[i].form ? "form" : "image", list[i].rect.x0, list[i].rect.y0, list[i].rect.x1, list[i].rect.y1);
|
||||
fz_append_byte(ctx, buf, ']');
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_free(ctx, list);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* What the rewrite does at draw `target`. */
|
||||
typedef struct
|
||||
{
|
||||
int target, seen, drop;
|
||||
fz_matrix x;
|
||||
const char *rename;
|
||||
pdf_processor *out;
|
||||
void (*image)(fz_context *, pdf_processor *, const char *, fz_image *);
|
||||
void (*form)(fz_context *, pdf_processor *, const char *, pdf_obj *);
|
||||
} hook;
|
||||
|
||||
/* Wraps the chosen draw in `x`, renames or drops it. */
|
||||
static int at_target(fz_context *ctx, hook *h, const char **name)
|
||||
{
|
||||
if (h->seen++ != h->target)
|
||||
return 0;
|
||||
if (!h->drop)
|
||||
{
|
||||
h->out->op_q(ctx, h->out);
|
||||
h->out->op_cm(ctx, h->out, h->x.a, h->x.b, h->x.c, h->x.d, h->x.e, h->x.f);
|
||||
if (h->rename)
|
||||
*name = h->rename;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void hook_image(fz_context *ctx, pdf_processor *proc, const char *name, fz_image *image)
|
||||
{
|
||||
hook *h = fz_user_context(ctx);
|
||||
int hit = at_target(ctx, h, &name);
|
||||
if (!(hit && h->drop))
|
||||
h->image(ctx, proc, name, image);
|
||||
if (hit && !h->drop)
|
||||
h->out->op_Q(ctx, h->out);
|
||||
}
|
||||
|
||||
static void hook_form(fz_context *ctx, pdf_processor *proc, const char *name, pdf_obj *form)
|
||||
{
|
||||
hook *h = fz_user_context(ctx);
|
||||
int hit = at_target(ctx, h, &name);
|
||||
if (!(hit && h->drop))
|
||||
h->form(ctx, proc, name, form);
|
||||
if (hit && !h->drop)
|
||||
h->out->op_Q(ctx, h->out);
|
||||
}
|
||||
|
||||
/* Rewrites the page's content with `h` at its target draw. */
|
||||
static void rewrite(fz_context *ctx, pdf_document *pdf, pdf_page *page, hook *h)
|
||||
{
|
||||
fz_buffer *buf = fz_new_buffer(ctx, 1024);
|
||||
pdf_processor *out = NULL;
|
||||
void *before = fz_user_context(ctx);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
out = pdf_new_buffer_processor(ctx, buf, 0, 0);
|
||||
h->out = out;
|
||||
h->image = out->op_Do_image;
|
||||
h->form = out->op_Do_form;
|
||||
out->op_Do_image = hook_image;
|
||||
out->op_Do_form = hook_form;
|
||||
fz_set_user_context(ctx, h);
|
||||
pdf_process_contents(ctx, out, pdf, pdf_page_resources(ctx, page), pdf_page_contents(ctx, page), NULL, NULL);
|
||||
pdf_close_processor(ctx, out);
|
||||
if (h->seen <= h->target)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no drawn object %d", h->target);
|
||||
pdf_dict_put_drop(ctx, page->obj, PDF_NAME(Contents), pdf_add_stream(ctx, pdf, buf, NULL, 0));
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_set_user_context(ctx, before);
|
||||
pdf_drop_processor(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* The draw's matrix in page space. */
|
||||
static fz_matrix placed_at(fz_context *ctx, pdf_document *pdf, pdf_page *page, int index, fz_rect *rect)
|
||||
{
|
||||
drawn *list = NULL;
|
||||
fz_matrix at = fz_identity, page_ctm;
|
||||
fz_var(list);
|
||||
fz_try(ctx)
|
||||
{
|
||||
int n = take_inventory(ctx, pdf, page, &list);
|
||||
if (index < 0 || index >= n)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no drawn object %d", index);
|
||||
pdf_page_transform(ctx, page, NULL, &page_ctm);
|
||||
at = fz_concat(list[index].ctm, page_ctm);
|
||||
*rect = list[index].rect;
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, list);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return at;
|
||||
}
|
||||
|
||||
/* Draw `index` moved by page-space `t`, swapped, or dropped. */
|
||||
int ely_place(fz_context *ctx, fz_document *doc, int number, int index, const float t[6], int drop, const char *path)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_image *image = NULL;
|
||||
pdf_obj *ref = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(image);
|
||||
fz_var(ref);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
hook h = { index, 0, drop, fz_identity, NULL, NULL, NULL, NULL };
|
||||
fz_matrix change = fz_make_matrix(t[0], t[1], t[2], t[3], t[4], t[5]), at;
|
||||
fz_rect rect;
|
||||
char name[32];
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
pdf_begin_operation(ctx, pdf, drop ? "Delete an object" : path ? "Replace an image" : "Move an object");
|
||||
begun = 1;
|
||||
at = placed_at(ctx, pdf, page, index, &rect);
|
||||
if (path)
|
||||
{
|
||||
/* The new picture fits inside the old one's box. */
|
||||
float box = (rect.x1 - rect.x0) / (rect.y1 - rect.y0), own;
|
||||
float cx = (rect.x0 + rect.x1) / 2, cy = (rect.y0 + rect.y1) / 2;
|
||||
image = fz_new_image_from_file(ctx, path);
|
||||
own = (float)image->w / image->h;
|
||||
change = fz_concat(fz_concat(fz_translate(-cx, -cy), fz_scale(fminf(1, own / box), fminf(1, box / own))), fz_translate(cx, cy));
|
||||
ref = pdf_add_image(ctx, pdf, image);
|
||||
h.rename = ely_add_resource(ctx, pdf, page->obj, PDF_NAME(XObject), "ElyIm", ref, name, sizeof name);
|
||||
}
|
||||
if (fz_matrix_expansion(change) < 0.001f)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a change that leaves nothing");
|
||||
h.x = fz_concat(fz_concat(at, change), fz_invert_matrix(at));
|
||||
rewrite(ctx, pdf, page, &h);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, ref);
|
||||
fz_drop_image(ctx, image);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* An image file drawn into page-space `rect`, in one step. */
|
||||
int ely_add_image(fz_context *ctx, fz_document *doc, int number, const char *path, const float rect[4])
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_image *image = NULL;
|
||||
pdf_obj *ref = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(image);
|
||||
fz_var(ref);
|
||||
fz_var(buf);
|
||||
fz_var(begun);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_matrix m;
|
||||
char name[32];
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
if (rect[2] - rect[0] < 1 || rect[3] - rect[1] < 1)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "an image box of nothing");
|
||||
image = fz_new_image_from_file(ctx, path);
|
||||
pdf_begin_operation(ctx, pdf, "Add an image");
|
||||
begun = 1;
|
||||
ref = pdf_add_image(ctx, pdf, image);
|
||||
ely_add_resource(ctx, pdf, page->obj, PDF_NAME(XObject), "ElyIm", ref, name, sizeof name);
|
||||
/* The unit square, bottom left first, onto the box. */
|
||||
m = fz_concat(fz_make_matrix(rect[2] - rect[0], 0, 0, rect[1] - rect[3], rect[0], rect[3]), ely_to_user(ctx, page));
|
||||
buf = fz_new_buffer(ctx, 128);
|
||||
fz_append_printf(ctx, buf, "q %g %g %g %g %g %g cm /%s Do Q\n", m.a, m.b, m.c, m.d, m.e, m.f, name);
|
||||
ely_append_content(ctx, pdf, page->obj, buf);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, buf);
|
||||
pdf_drop_obj(ctx, ref);
|
||||
fz_drop_image(ctx, image);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/* True removal into a new file, then checked (PDF-09, QA-14). */
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* Regions by page, page space. */
|
||||
typedef struct
|
||||
{
|
||||
int page;
|
||||
fz_rect rect;
|
||||
} region;
|
||||
|
||||
static int touches(fz_rect a, fz_rect b)
|
||||
{
|
||||
return !fz_is_empty_rect(fz_intersect_rect(a, b));
|
||||
}
|
||||
|
||||
/* Throws unless every region is whole and on a page. */
|
||||
static region *regions_of(fz_context *ctx, fz_json *list, int pages, int *n)
|
||||
{
|
||||
region *out;
|
||||
int i;
|
||||
*n = list && list->type == FZ_JSON_ARRAY ? fz_json_array_length(ctx, list) : 0;
|
||||
if (*n == 0)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "nothing marked to remove");
|
||||
out = fz_malloc_array(ctx, *n, region);
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (i = 0; i < *n; i++)
|
||||
{
|
||||
fz_json *spec = fz_json_array_get(ctx, list, i);
|
||||
fz_json *page = ely_member(ctx, spec, "page", FZ_JSON_NUMBER);
|
||||
if (!page || page->u.number < 0 || page->u.number >= pages)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a region off the pages");
|
||||
out[i].page = (int)page->u.number;
|
||||
out[i].rect = ely_rect(ctx, ely_member(ctx, spec, "rect", FZ_JSON_ARRAY), 1);
|
||||
}
|
||||
}
|
||||
fz_catch(ctx)
|
||||
{
|
||||
fz_free(ctx, out);
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Drops annotations and fields that touch `area`; they carry text. */
|
||||
static void drop_touching(fz_context *ctx, pdf_page *page, fz_rect area)
|
||||
{
|
||||
pdf_annot *annot;
|
||||
int widgets;
|
||||
for (widgets = 0; widgets < 2; widgets++)
|
||||
{
|
||||
restart:
|
||||
for (annot = widgets ? pdf_first_widget(ctx, page) : pdf_first_annot(ctx, page); annot;
|
||||
annot = widgets ? pdf_next_widget(ctx, annot) : pdf_next_annot(ctx, annot))
|
||||
if (touches(pdf_bound_annot(ctx, annot), area))
|
||||
{
|
||||
pdf_obj *field = pdf_keep_obj(ctx, pdf_annot_obj(ctx, annot));
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_delete_annot(ctx, page, annot);
|
||||
/* A field left with no widget still holds its value. */
|
||||
if (widgets)
|
||||
ely_unlink_field(ctx, page->doc, field);
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_drop_obj(ctx, field);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
goto restart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Applies `regions` to the copy, page by page. */
|
||||
static void redact(fz_context *ctx, pdf_document *pdf, region *regions, int n)
|
||||
{
|
||||
pdf_redact_options opts = { 1, PDF_REDACT_IMAGE_PIXELS, PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED, PDF_REDACT_TEXT_REMOVE };
|
||||
pdf_page *page = NULL;
|
||||
int i, k;
|
||||
fz_var(page);
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (i = 0; i < pdf_count_pages(ctx, pdf); i++)
|
||||
{
|
||||
int any = 0;
|
||||
for (k = 0; k < n; k++)
|
||||
any |= regions[k].page == i;
|
||||
if (!any)
|
||||
continue;
|
||||
page = pdf_load_page(ctx, pdf, i);
|
||||
for (k = 0; k < n; k++)
|
||||
if (regions[k].page == i)
|
||||
drop_touching(ctx, page, regions[k].rect);
|
||||
/* Marks after drops, or a drop takes a mark. */
|
||||
for (k = 0; k < n; k++)
|
||||
if (regions[k].page == i)
|
||||
{
|
||||
pdf_annot *mark = pdf_create_annot(ctx, page, PDF_ANNOT_REDACT);
|
||||
pdf_set_annot_rect(ctx, mark, regions[k].rect);
|
||||
pdf_drop_annot(ctx, mark);
|
||||
}
|
||||
pdf_redact_page(ctx, pdf, page, &opts);
|
||||
ely_drop_alt(ctx, pdf, page);
|
||||
pdf_dict_del(ctx, page->obj, PDF_NAME(Thumb));
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
page = NULL;
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* A glyph box shrunk a tenth, as redaction judges. */
|
||||
static fz_rect shrunk(fz_rect r)
|
||||
{
|
||||
float w = (r.x1 - r.x0) / 10, h = (r.y1 - r.y0) / 10;
|
||||
return fz_make_rect(r.x0 + w, r.y0 + h, r.x1 - w, r.y1 - h);
|
||||
}
|
||||
|
||||
/* Characters on `page` inside `area`, hidden ones too. */
|
||||
static int chars_in(fz_context *ctx, fz_document *doc, int number, fz_rect area)
|
||||
{
|
||||
fz_stext_page *text = NULL;
|
||||
int n = 0;
|
||||
fz_var(text);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_stext_block *block;
|
||||
fz_stext_line *line;
|
||||
fz_stext_char *ch;
|
||||
text = fz_new_stext_page_from_page_number(ctx, doc, number, NULL);
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
if (block->type == FZ_STEXT_BLOCK_TEXT)
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
n += touches(shrunk(fz_rect_from_quad(ch->quad)), area);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Pixels in `area` that are not black once drawn. */
|
||||
static int light_in(fz_context *ctx, fz_document *doc, int number, fz_rect area)
|
||||
{
|
||||
fz_pixmap *pix = NULL;
|
||||
int n = 0;
|
||||
fz_var(pix);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_page *page = fz_load_page(ctx, doc, number);
|
||||
fz_irect box = fz_round_rect(fz_intersect_rect(area, fz_bound_page(ctx, page)));
|
||||
unsigned char *s;
|
||||
int x, y;
|
||||
fz_drop_page(ctx, page);
|
||||
/* One pixel in from each edge: boxes antialias. */
|
||||
box.x0++, box.y0++, box.x1--, box.y1--;
|
||||
if (box.x1 <= box.x0 || box.y1 <= box.y0)
|
||||
break;
|
||||
pix = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), box, NULL, 0);
|
||||
fz_clear_pixmap_with_value(ctx, pix, 255);
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_device *dev = fz_new_draw_device(ctx, fz_identity, pix);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_run_page(ctx, page, dev, fz_identity, NULL);
|
||||
fz_close_device(ctx, dev);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_device(ctx, dev);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_page(ctx, page);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
for (y = 0, s = pix->samples; y < pix->h; y++, s += pix->stride - pix->w * 3)
|
||||
for (x = 0; x < pix->w; x++, s += 3)
|
||||
n += s[0] > 40 || s[1] > 40 || s[2] > 40;
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_pixmap(ctx, pix);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Counts drawings in the regions, our boxes aside. */
|
||||
typedef struct
|
||||
{
|
||||
fz_device super;
|
||||
region *regions;
|
||||
int n, page, leaks;
|
||||
} leak_dev;
|
||||
|
||||
static int in_regions(leak_dev *d, fz_rect r)
|
||||
{
|
||||
int k;
|
||||
for (k = 0; k < d->n; k++)
|
||||
if (d->regions[k].page == d->page && touches(r, d->regions[k].rect))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int black(fz_context *ctx, fz_colorspace *cs, const float *color)
|
||||
{
|
||||
float rgb[3];
|
||||
if (!cs)
|
||||
return 0;
|
||||
fz_convert_color(ctx, cs, color, fz_device_rgb(ctx), rgb, NULL, fz_default_color_params);
|
||||
return rgb[0] <= 0.02f && rgb[1] <= 0.02f && rgb[2] <= 0.02f;
|
||||
}
|
||||
|
||||
/* Whether `r`, black, is one of the region boxes. */
|
||||
static int is_box(fz_context *ctx, leak_dev *d, fz_rect r, fz_colorspace *cs, const float *color)
|
||||
{
|
||||
int k;
|
||||
if (!black(ctx, cs, color))
|
||||
return 0;
|
||||
for (k = 0; k < d->n; k++)
|
||||
{
|
||||
fz_rect b = d->regions[k].rect;
|
||||
if (d->regions[k].page == d->page && fabsf(r.x0 - b.x0) < 1 && fabsf(r.y0 - b.y0) < 1 &&
|
||||
fabsf(r.x1 - b.x1) < 1 && fabsf(r.y1 - b.y1) < 1)
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void leak_fill_path(fz_context *ctx, fz_device *dev, const fz_path *path, int even_odd, fz_matrix ctm,
|
||||
fz_colorspace *cs, const float *color, float alpha, fz_color_params cp)
|
||||
{
|
||||
leak_dev *d = (leak_dev *)dev;
|
||||
fz_rect r = fz_bound_path(ctx, path, NULL, ctm);
|
||||
(void)even_odd, (void)alpha, (void)cp;
|
||||
if (in_regions(d, r) && !is_box(ctx, d, r, cs, color))
|
||||
d->leaks++;
|
||||
}
|
||||
|
||||
static void leak_stroke_path(fz_context *ctx, fz_device *dev, const fz_path *path, const fz_stroke_state *stroke,
|
||||
fz_matrix ctm, fz_colorspace *cs, const float *color, float alpha, fz_color_params cp)
|
||||
{
|
||||
leak_dev *d = (leak_dev *)dev;
|
||||
(void)cs, (void)color, (void)alpha, (void)cp;
|
||||
d->leaks += in_regions(d, fz_bound_path(ctx, path, stroke, ctm));
|
||||
}
|
||||
|
||||
static void leak_fill_shade(fz_context *ctx, fz_device *dev, fz_shade *shade, fz_matrix ctm, float alpha, fz_color_params cp)
|
||||
{
|
||||
leak_dev *d = (leak_dev *)dev;
|
||||
(void)alpha, (void)cp;
|
||||
d->leaks += in_regions(d, fz_bound_shade(ctx, shade, ctm));
|
||||
}
|
||||
|
||||
static void leak_fill_image_mask(fz_context *ctx, fz_device *dev, fz_image *image, fz_matrix ctm, fz_colorspace *cs,
|
||||
const float *color, float alpha, fz_color_params cp)
|
||||
{
|
||||
leak_dev *d = (leak_dev *)dev;
|
||||
(void)image, (void)alpha, (void)cp;
|
||||
d->leaks += in_regions(d, fz_transform_rect(fz_unit_rect, ctm)) && !black(ctx, cs, color);
|
||||
}
|
||||
|
||||
/* Image pixels in `area` neither white nor black. */
|
||||
static int image_light(fz_context *ctx, fz_image *image, fz_matrix ctm, fz_rect area)
|
||||
{
|
||||
fz_irect box = fz_round_rect(fz_intersect_rect(area, fz_transform_rect(fz_unit_rect, ctm)));
|
||||
fz_pixmap *pix = NULL;
|
||||
fz_device *draw = NULL;
|
||||
int n = 0, x, y;
|
||||
box.x0++, box.y0++, box.x1--, box.y1--;
|
||||
if (box.x1 <= box.x0 || box.y1 <= box.y0)
|
||||
return 0;
|
||||
fz_var(pix);
|
||||
fz_var(draw);
|
||||
fz_try(ctx)
|
||||
{
|
||||
unsigned char *s;
|
||||
pix = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), box, NULL, 1);
|
||||
fz_clear_pixmap(ctx, pix);
|
||||
draw = fz_new_draw_device(ctx, fz_identity, pix);
|
||||
fz_fill_image(ctx, draw, image, ctm, 1, fz_default_color_params);
|
||||
fz_close_device(ctx, draw);
|
||||
for (y = 0, s = pix->samples; y < pix->h; y++, s += pix->stride - pix->w * 4)
|
||||
for (x = 0; x < pix->w; x++, s += 4)
|
||||
{
|
||||
int high = s[0] > 215 && s[1] > 215 && s[2] > 215, low = s[0] < 40 && s[1] < 40 && s[2] < 40;
|
||||
n += s[3] > 200 && !high && !low;
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_device(ctx, draw);
|
||||
fz_drop_pixmap(ctx, pix);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return n;
|
||||
}
|
||||
|
||||
static void leak_fill_image(fz_context *ctx, fz_device *dev, fz_image *image, fz_matrix ctm, float alpha, fz_color_params cp)
|
||||
{
|
||||
leak_dev *d = (leak_dev *)dev;
|
||||
int k;
|
||||
(void)alpha, (void)cp;
|
||||
for (k = 0; k < d->n; k++)
|
||||
if (d->regions[k].page == d->page)
|
||||
d->leaks += image_light(ctx, image, ctm, d->regions[k].rect);
|
||||
}
|
||||
|
||||
/* Drawings left in the regions of page `number`. */
|
||||
static int leaks_on(fz_context *ctx, fz_document *doc, int number, region *regions, int n)
|
||||
{
|
||||
leak_dev *d = fz_new_derived_device(ctx, leak_dev);
|
||||
fz_page *page = NULL;
|
||||
int leaks = 0;
|
||||
fz_var(page);
|
||||
d->super.fill_path = leak_fill_path;
|
||||
d->super.stroke_path = leak_stroke_path;
|
||||
d->super.fill_shade = leak_fill_shade;
|
||||
d->super.fill_image = leak_fill_image;
|
||||
d->super.fill_image_mask = leak_fill_image_mask;
|
||||
d->regions = regions, d->n = n, d->page = number;
|
||||
fz_try(ctx)
|
||||
{
|
||||
page = fz_load_page(ctx, doc, number);
|
||||
fz_run_page(ctx, page, &d->super, fz_identity, NULL);
|
||||
fz_close_device(ctx, &d->super);
|
||||
leaks = d->leaks;
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_page(ctx, page);
|
||||
fz_drop_device(ctx, &d->super);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return leaks;
|
||||
}
|
||||
|
||||
typedef struct
|
||||
{
|
||||
region *regions;
|
||||
int n;
|
||||
} marked;
|
||||
|
||||
static void apply_marks(fz_context *ctx, fz_document *copy, void *arg)
|
||||
{
|
||||
marked *m = arg;
|
||||
redact(ctx, ely_pdf_of(ctx, copy), m->regions, m->n);
|
||||
}
|
||||
|
||||
/* Checks `out`: no text, no light in any region. */
|
||||
static void check_redacted(fz_context *ctx, const char *out, const char *password, region *regions, int n, fz_buffer *report)
|
||||
{
|
||||
fz_document *doc = fz_open_document(ctx, out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
int i, text = 0, light = 0, objects = 0, versions;
|
||||
if (fz_needs_password(ctx, doc) && !fz_authenticate_password(ctx, doc, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the written file refused its password");
|
||||
for (i = 0; i < n; i++)
|
||||
{
|
||||
text += chars_in(ctx, doc, regions[i].page, regions[i].rect);
|
||||
light += light_in(ctx, doc, regions[i].page, regions[i].rect);
|
||||
objects += pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, ely_pdf_of(ctx, doc), regions[i].page), PDF_NAME(Thumb)) != NULL;
|
||||
}
|
||||
for (i = 0; i < fz_count_pages(ctx, doc); i++)
|
||||
objects += leaks_on(ctx, doc, i, regions, n);
|
||||
versions = pdf_count_versions(ctx, ely_pdf_of(ctx, doc));
|
||||
fz_append_printf(ctx, report, "{\"regions\":%d,\"text\":%d,\"light\":%d,\"objects\":%d,\"versions\":%d}", n, text,
|
||||
light, objects, versions);
|
||||
if (text || light || objects || versions != 1)
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "the written file still holds what was removed: %d characters, %d light pixels, %d drawings, %d versions",
|
||||
text, light, objects, versions);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_document(ctx, doc);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Removes the marked regions into `out`, then checks it. */
|
||||
int ely_redact(fz_context *ctx, fz_document *doc, const char *json, const char *raw, const char *out,
|
||||
const char *password, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_pool *pool = NULL;
|
||||
marked m = { NULL, 0 };
|
||||
fz_buffer *report = NULL;
|
||||
fz_var(pool);
|
||||
fz_var(m);
|
||||
fz_var(report);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
pool = fz_new_pool(ctx);
|
||||
m.regions = regions_of(ctx, fz_parse_json(ctx, pool, json), pdf_count_pages(ctx, pdf), &m.n);
|
||||
ely_save_copy(ctx, pdf, raw, out, password, NULL, apply_marks, &m);
|
||||
report = fz_new_buffer(ctx, 128);
|
||||
check_redacted(ctx, out, password, m.regions, m.n, report);
|
||||
*len = fz_buffer_extract(ctx, report, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, report);
|
||||
fz_free(ctx, m.regions);
|
||||
fz_drop_pool(ctx, pool);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Permissions and protected copies (PDF-11). */
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
enum { MAY_PRINT = 1, MAY_COPY = 2, MAY_EDIT = 4, MAY_ANNOTATE = 8, MAY_FILL = 16, MAY_ASSEMBLE = 32 };
|
||||
|
||||
/* What the document allows, as Ely's bits. */
|
||||
int ely_permissions(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
int may = 0;
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
int edit = pdf_has_permission(ctx, pdf, FZ_PERMISSION_EDIT) != 0;
|
||||
int annotate = pdf_has_permission(ctx, pdf, FZ_PERMISSION_ANNOTATE) != 0;
|
||||
may |= pdf_has_permission(ctx, pdf, FZ_PERMISSION_PRINT) ? MAY_PRINT : 0;
|
||||
may |= pdf_has_permission(ctx, pdf, FZ_PERMISSION_COPY) ? MAY_COPY : 0;
|
||||
may |= edit ? MAY_EDIT : 0;
|
||||
may |= annotate ? MAY_ANNOTATE : 0;
|
||||
may |= annotate || pdf_has_permission(ctx, pdf, FZ_PERMISSION_FORM) ? MAY_FILL : 0;
|
||||
may |= edit || pdf_has_permission(ctx, pdf, FZ_PERMISSION_ASSEMBLE) ? MAY_ASSEMBLE : 0;
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return may;
|
||||
}
|
||||
|
||||
/* PDF's permission bits for Ely's `may`. */
|
||||
static int pdf_bits(int may)
|
||||
{
|
||||
int p = ~(PDF_PERM_PRINT | PDF_PERM_MODIFY | PDF_PERM_COPY | PDF_PERM_ANNOTATE | PDF_PERM_FORM |
|
||||
PDF_PERM_ASSEMBLE | PDF_PERM_PRINT_HQ | 3);
|
||||
p |= may & MAY_PRINT ? PDF_PERM_PRINT | PDF_PERM_PRINT_HQ : 0;
|
||||
p |= may & MAY_COPY ? PDF_PERM_COPY : 0;
|
||||
p |= may & MAY_EDIT ? PDF_PERM_MODIFY : 0;
|
||||
p |= may & MAY_ANNOTATE ? PDF_PERM_ANNOTATE : 0;
|
||||
p |= may & MAY_FILL ? PDF_PERM_FORM : 0;
|
||||
p |= may & MAY_ASSEMBLE ? PDF_PERM_ASSEMBLE : 0;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void put_password(fz_context *ctx, char *into, const char *password)
|
||||
{
|
||||
if (strlen(password) >= 128)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a password longer than 127 bytes");
|
||||
strcpy(into, password);
|
||||
}
|
||||
|
||||
/* Writes `out` with AES-256 and `may`, or unprotected. */
|
||||
int ely_protect(fz_context *ctx, fz_document *doc, const char *raw, const char *out, const char *password,
|
||||
int protect, const char *user, const char *owner, int may)
|
||||
{
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_write_options opts = pdf_default_write_options;
|
||||
opts.do_encrypt = protect ? PDF_ENCRYPT_AES_256 : PDF_ENCRYPT_NONE;
|
||||
if (protect)
|
||||
{
|
||||
if (!*owner)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "an owner password is needed");
|
||||
if (!strcmp(user, owner))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the owner password equals the open password");
|
||||
put_password(ctx, opts.upwd_utf8, user);
|
||||
put_password(ctx, opts.opwd_utf8, owner);
|
||||
opts.permissions = pdf_bits(may);
|
||||
}
|
||||
ely_save_copy(ctx, ely_pdf_of(ctx, doc), raw, out, password, &opts, NULL, NULL);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/* What the shim files share. */
|
||||
#ifndef ELY_SHIM_H
|
||||
#define ELY_SHIM_H
|
||||
|
||||
#include <mupdf/fitz.h>
|
||||
#include <mupdf/pdf.h>
|
||||
|
||||
int ely_fail(fz_context *ctx);
|
||||
const char *ely_error(void);
|
||||
int ely_render(fz_context *ctx, fz_document *doc, int number, float scale, int rotate, const int tile[4], int dark,
|
||||
unsigned char *bgra);
|
||||
void ely_fail_also(fz_context *ctx, const char *what);
|
||||
pdf_document *ely_pdf_of(fz_context *ctx, fz_document *doc);
|
||||
int ely_edit_failed(fz_context *ctx, pdf_document *pdf, int begun);
|
||||
void ely_forget_resources(fz_context *ctx, pdf_document *pdf);
|
||||
pdf_obj *ely_target(fz_context *ctx, pdf_document *pdf, pdf_obj *obj);
|
||||
typedef void (ely_change)(fz_context *ctx, fz_document *copy, void *arg);
|
||||
void ely_save_copy(fz_context *ctx, pdf_document *pdf, const char *raw, const char *out, const char *password,
|
||||
const pdf_write_options *opts,
|
||||
ely_change *change, void *arg);
|
||||
|
||||
/* A text block as read, page space, and its glyphs. */
|
||||
typedef struct
|
||||
{
|
||||
void *glyphs;
|
||||
fz_font *font;
|
||||
fz_point first;
|
||||
float size;
|
||||
int rgb;
|
||||
fz_rect bbox;
|
||||
int lines;
|
||||
float gap;
|
||||
} ely_shape;
|
||||
|
||||
fz_matrix ely_to_user(fz_context *ctx, pdf_page *page);
|
||||
void ely_read_block(fz_context *ctx, fz_document *doc, pdf_page *page, int number, int index, const char *expected,
|
||||
ely_shape *shape);
|
||||
void ely_remove_block(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_shape *shape);
|
||||
void ely_drop_shape(fz_context *ctx, ely_shape *shape);
|
||||
pdf_obj *ely_append_content(fz_context *ctx, pdf_document *pdf, pdf_obj *page, fz_buffer *buf);
|
||||
const char *ely_add_resource(fz_context *ctx, pdf_document *pdf, pdf_obj *page, pdf_obj *type, const char *stem,
|
||||
pdf_obj *ref, char *name, size_t room);
|
||||
void ely_json_string(fz_context *ctx, fz_output *out, const char *s);
|
||||
fz_json *ely_member(fz_context *ctx, fz_json *obj, const char *key, int type);
|
||||
float ely_number(fz_context *ctx, fz_json *array, int at);
|
||||
fz_rect ely_rect(fz_context *ctx, fz_json *array, float least);
|
||||
pdf_annot *ely_find_annot(fz_context *ctx, pdf_page *page, int id, int widget);
|
||||
void ely_write_fields(fz_context *ctx, fz_output *out, pdf_page *page);
|
||||
void ely_unlink_field(fz_context *ctx, pdf_document *pdf, pdf_obj *field);
|
||||
void ely_unsign(fz_context *ctx, pdf_document *pdf);
|
||||
void ely_watch_begin(fz_context *ctx);
|
||||
int ely_watch_end(fz_context *ctx);
|
||||
const char *ely_open_warnings(void);
|
||||
void ely_drop_alt(fz_context *ctx, pdf_document *pdf, pdf_page *page);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,409 @@
|
||||
/* Signatures: read and checked (PDF-12, QA-15). */
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <openssl/pkcs7.h>
|
||||
#include <openssl/x509.h>
|
||||
|
||||
#include <mupdf/helpers/pkcs7-openssl.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
static const char *digest_name(pdf_signature_error error)
|
||||
{
|
||||
switch (error)
|
||||
{
|
||||
case PDF_SIGNATURE_ERROR_OKAY: return "ok";
|
||||
case PDF_SIGNATURE_ERROR_DIGEST_FAILURE: return "changed";
|
||||
case PDF_SIGNATURE_ERROR_NO_CERTIFICATE: return "no_certificate";
|
||||
default: return "unreadable";
|
||||
}
|
||||
}
|
||||
|
||||
static void put_hex(fz_context *ctx, fz_output *out, const unsigned char *data, int len)
|
||||
{
|
||||
int i;
|
||||
fz_write_byte(ctx, out, '"');
|
||||
for (i = 0; i < len; i++)
|
||||
fz_write_printf(ctx, out, "%02x", data[i]);
|
||||
fz_write_byte(ctx, out, '"');
|
||||
}
|
||||
|
||||
static void put_cert(fz_context *ctx, fz_output *out, X509 *cert, int *first)
|
||||
{
|
||||
unsigned char *der = NULL;
|
||||
int len = i2d_X509(cert, &der);
|
||||
if (len <= 0)
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "a certificate that will not encode");
|
||||
fz_try(ctx)
|
||||
{
|
||||
if (!*first)
|
||||
fz_write_byte(ctx, out, ',');
|
||||
*first = 0;
|
||||
put_hex(ctx, out, der, len);
|
||||
}
|
||||
fz_always(ctx)
|
||||
OPENSSL_free(der);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* The signer's certificate first, then the rest it carries. */
|
||||
static void put_certs(fz_context *ctx, fz_output *out, const char *contents, size_t len)
|
||||
{
|
||||
const unsigned char *at = (const unsigned char *)contents;
|
||||
PKCS7 *p7 = len < INT_MAX ? d2i_PKCS7(NULL, &at, (long)len) : NULL;
|
||||
STACK_OF(X509) *signers = NULL, *carried;
|
||||
int i, first = 1;
|
||||
if (!p7 || !PKCS7_type_is_signed(p7))
|
||||
{
|
||||
PKCS7_free(p7);
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "a signature that is not PKCS #7 signed data");
|
||||
}
|
||||
fz_try(ctx)
|
||||
{
|
||||
carried = p7->d.sign->cert;
|
||||
signers = PKCS7_get0_signers(p7, NULL, 0);
|
||||
fz_write_string(ctx, out, "[");
|
||||
for (i = 0; i < sk_X509_num(signers); i++)
|
||||
put_cert(ctx, out, sk_X509_value(signers, i), &first);
|
||||
for (i = 0; i < sk_X509_num(carried); i++)
|
||||
if (sk_X509_find(signers, sk_X509_value(carried, i)) < 0)
|
||||
put_cert(ctx, out, sk_X509_value(carried, i), &first);
|
||||
fz_write_string(ctx, out, "]");
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
sk_X509_free(signers);
|
||||
PKCS7_free(p7);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
static void put_text(fz_context *ctx, fz_output *out, const char *key, pdf_obj *value)
|
||||
{
|
||||
fz_write_printf(ctx, out, ",\"%s\":", key);
|
||||
ely_json_string(ctx, out, pdf_is_string(ctx, value) ? pdf_to_text_string(ctx, value) : "");
|
||||
}
|
||||
|
||||
static void put_signed(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *field, pdf_pkcs7_verifier *verifier)
|
||||
{
|
||||
pdf_obj *v = pdf_dict_get_inheritable(ctx, field, PDF_NAME(V));
|
||||
pdf_pkcs7_distinguished_name *dn = pdf_signature_get_signatory(ctx, verifier, pdf, field);
|
||||
char *signer = NULL, *contents = NULL;
|
||||
size_t len;
|
||||
fz_var(signer);
|
||||
fz_var(contents);
|
||||
fz_try(ctx)
|
||||
{
|
||||
signer = dn ? pdf_signature_format_distinguished_name(ctx, dn) : NULL;
|
||||
fz_write_printf(ctx, out, ",\"digest\":\"%s\"", digest_name(pdf_check_digest(ctx, verifier, pdf, field)));
|
||||
fz_write_printf(ctx, out, ",\"later\":%d", pdf_signature_incremental_change_since_signing(ctx, pdf, field));
|
||||
fz_write_printf(ctx, out, ",\"broken\":%d", pdf_validate_signature(ctx, pdf, field));
|
||||
fz_write_string(ctx, out, ",\"signer\":");
|
||||
ely_json_string(ctx, out, signer ? signer : "");
|
||||
fz_write_string(ctx, out, ",\"common\":");
|
||||
ely_json_string(ctx, out, dn && dn->cn ? dn->cn : "");
|
||||
put_text(ctx, out, "date", pdf_dict_get(ctx, v, PDF_NAME(M)));
|
||||
put_text(ctx, out, "reason", pdf_dict_get(ctx, v, PDF_NAME(Reason)));
|
||||
put_text(ctx, out, "location", pdf_dict_get(ctx, v, PDF_NAME(Location)));
|
||||
len = pdf_signature_contents(ctx, pdf, field, &contents);
|
||||
fz_write_string(ctx, out, ",\"certs\":");
|
||||
put_certs(ctx, out, contents, len);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_free(ctx, contents);
|
||||
fz_free(ctx, signer);
|
||||
pdf_signature_drop_distinguished_name(ctx, dn);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* A broken signature reports why; the others still list. */
|
||||
static void put_checked(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *field, pdf_pkcs7_verifier *verifier)
|
||||
{
|
||||
fz_buffer *buf = fz_new_buffer(ctx, 256);
|
||||
fz_output *part = NULL;
|
||||
char why[256] = "";
|
||||
fz_var(part);
|
||||
fz_try(ctx)
|
||||
{
|
||||
part = fz_new_output_with_buffer(ctx, buf);
|
||||
put_signed(ctx, part, pdf, field, verifier);
|
||||
fz_close_output(ctx, part);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_output(ctx, part);
|
||||
fz_catch(ctx)
|
||||
{
|
||||
fz_strlcpy(why, fz_caught_message(ctx), sizeof why);
|
||||
fz_ignore_error(ctx);
|
||||
}
|
||||
fz_try(ctx)
|
||||
{
|
||||
if (*why)
|
||||
{
|
||||
fz_write_string(ctx, out, ",\"digest\":\"unreadable\",\"error\":");
|
||||
ely_json_string(ctx, out, why);
|
||||
}
|
||||
else
|
||||
fz_write_buffer(ctx, out, buf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* A widget's page and page-space box; -1 for none. */
|
||||
static int placed(fz_context *ctx, pdf_document *pdf, pdf_obj *field, fz_rect *box)
|
||||
{
|
||||
pdf_obj *widget = pdf_name_eq(ctx, pdf_dict_get(ctx, field, PDF_NAME(Subtype)), PDF_NAME(Widget)) ? field :
|
||||
pdf_array_get(ctx, pdf_dict_get(ctx, field, PDF_NAME(Kids)), 0);
|
||||
pdf_obj *page = pdf_dict_get(ctx, widget, PDF_NAME(P));
|
||||
fz_matrix ctm;
|
||||
int i, k, number = page ? pdf_lookup_page_number(ctx, pdf, page) : -1;
|
||||
/* /P is optional; the page's /Annots tell. */
|
||||
for (i = 0; number < 0 && i < pdf_count_pages(ctx, pdf); i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, pdf, i), PDF_NAME(Annots));
|
||||
for (k = 0; k < pdf_array_len(ctx, annots); k++)
|
||||
if (pdf_to_num(ctx, pdf_array_get(ctx, annots, k)) == pdf_to_num(ctx, widget))
|
||||
number = i, page = pdf_lookup_page_obj(ctx, pdf, i);
|
||||
}
|
||||
*box = fz_empty_rect;
|
||||
if (number >= 0)
|
||||
{
|
||||
pdf_page_obj_transform(ctx, page, NULL, &ctm);
|
||||
*box = fz_transform_rect(pdf_dict_get_rect(ctx, widget, PDF_NAME(Rect)), ctm);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
static void put_field(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *field, pdf_pkcs7_verifier *verifier, int *first)
|
||||
{
|
||||
fz_rect r;
|
||||
int number = placed(ctx, pdf, field, &r), signd = pdf_signature_is_signed(ctx, pdf, field);
|
||||
char *name = pdf_load_field_name(ctx, field);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_write_printf(ctx, out, "%s{\"page\":%d,\"object\":%d,\"rect\":[%g,%g,%g,%g],\"signed\":%s,\"name\":",
|
||||
*first ? "" : ",", number, pdf_to_num(ctx, field), r.x0, r.y0, r.x1, r.y1, signd ? "true" : "false");
|
||||
*first = 0;
|
||||
ely_json_string(ctx, out, name);
|
||||
if (signd)
|
||||
put_checked(ctx, out, pdf, field, verifier);
|
||||
fz_write_byte(ctx, out, '}');
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_free(ctx, name);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Signature fields in the form tree, placed or not. */
|
||||
static void put_tree(fz_context *ctx, fz_output *out, pdf_document *pdf, pdf_obj *node, pdf_pkcs7_verifier *verifier, int *first)
|
||||
{
|
||||
pdf_obj *kids = pdf_dict_get(ctx, node, PDF_NAME(Kids));
|
||||
int i, leaf = 1;
|
||||
if (pdf_mark_obj(ctx, node))
|
||||
return;
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
leaf &= pdf_dict_get(ctx, pdf_array_get(ctx, kids, i), PDF_NAME(T)) == NULL;
|
||||
if (!leaf)
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
put_tree(ctx, out, pdf, pdf_array_get(ctx, kids, i), verifier, first);
|
||||
else if (pdf_name_eq(ctx, pdf_dict_get_inheritable(ctx, node, PDF_NAME(FT)), PDF_NAME(Sig)))
|
||||
put_field(ctx, out, pdf, node, verifier, first);
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_unmark_obj(ctx, node);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Every signature field, as JSON, with its check. */
|
||||
int ely_signatures(fz_context *ctx, fz_document *doc, unsigned char **data, size_t *len)
|
||||
{
|
||||
pdf_pkcs7_verifier *verifier = NULL;
|
||||
fz_buffer *buf = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(verifier);
|
||||
fz_var(buf);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
pdf_obj *fields = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields");
|
||||
int i, first = 1;
|
||||
verifier = pkcs7_openssl_new_verifier(ctx);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_byte(ctx, out, '[');
|
||||
for (i = 0; i < pdf_array_len(ctx, fields); i++)
|
||||
put_tree(ctx, out, pdf, pdf_array_get(ctx, fields, i), verifier, &first);
|
||||
fz_write_byte(ctx, out, ']');
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
pdf_drop_verifier(ctx, verifier);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* An empty field by number, or a new one. */
|
||||
static pdf_annot *sign_field(fz_context *ctx, pdf_document *pdf, pdf_page *page, int object, const float *rect, const char *name)
|
||||
{
|
||||
pdf_obj *form = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm/Fields");
|
||||
pdf_annot *widget;
|
||||
if (object >= 0)
|
||||
{
|
||||
widget = ely_find_annot(ctx, page, object, 1);
|
||||
if (pdf_widget_type(ctx, widget) != PDF_WIDGET_TYPE_SIGNATURE)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "field %d is not a signature field", object);
|
||||
if (pdf_signature_is_signed(ctx, pdf, pdf_annot_obj(ctx, widget)))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "field %d is signed already", object);
|
||||
return widget;
|
||||
}
|
||||
if (!*name || strchr(name, '.') || (form && pdf_lookup_field(ctx, form, name)))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a signature field needs a new name without dots");
|
||||
widget = pdf_create_signature_widget(ctx, page, (char *)name);
|
||||
pdf_set_annot_rect(ctx, widget, fz_make_rect(rect[0], rect[1], rect[2], rect[3]));
|
||||
return widget;
|
||||
}
|
||||
|
||||
/* Signs into `raw` by an update, then checks it. */
|
||||
int ely_sign(fz_context *ctx, fz_document *doc, const char *raw, const char *password, const char *pfx, const char *pfx_password,
|
||||
int number, int object, const float *rect, const char *name, const char *reason, const char *location)
|
||||
{
|
||||
pdf_pkcs7_signer *signer = NULL;
|
||||
pdf_pkcs7_verifier *verifier = NULL;
|
||||
fz_document *copy = NULL, *signd = NULL;
|
||||
pdf_page *page = NULL;
|
||||
pdf_obj *made = NULL;
|
||||
fz_var(made);
|
||||
fz_var(signer);
|
||||
fz_var(verifier);
|
||||
fz_var(copy);
|
||||
fz_var(signd);
|
||||
fz_var(page);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_write_options update = pdf_default_write_options;
|
||||
pdf_document *pdf;
|
||||
pdf_annot *widget;
|
||||
int field;
|
||||
signer = pkcs7_openssl_read_pfx(ctx, pfx, pfx_password);
|
||||
pdf_save_snapshot(ctx, ely_pdf_of(ctx, doc), raw);
|
||||
copy = fz_open_document(ctx, raw);
|
||||
if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the copy refused its password");
|
||||
pdf = ely_pdf_of(ctx, copy);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
widget = sign_field(ctx, pdf, page, object, rect, name);
|
||||
field = pdf_to_num(ctx, pdf_annot_obj(ctx, widget));
|
||||
pdf_sign_signature(ctx, widget, signer, PDF_SIGNATURE_SHOW_LABELS | PDF_SIGNATURE_SHOW_DATE | PDF_SIGNATURE_SHOW_TEXT_NAME, NULL, reason, location);
|
||||
/* Earlier signatures cover earlier bytes; only append. */
|
||||
update.do_incremental = 1;
|
||||
pdf_save_document(ctx, pdf, raw, &update);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
page = NULL;
|
||||
signd = fz_open_document(ctx, raw);
|
||||
if (fz_needs_password(ctx, signd) && !fz_authenticate_password(ctx, signd, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the signed copy refused its password");
|
||||
verifier = pkcs7_openssl_new_verifier(ctx);
|
||||
made = pdf_new_indirect(ctx, ely_pdf_of(ctx, signd), field, 0);
|
||||
if (pdf_check_digest(ctx, verifier, ely_pdf_of(ctx, signd), made) != PDF_SIGNATURE_ERROR_OKAY)
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "the new signature does not check");
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_drop_obj(ctx, made);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_drop_document(ctx, signd);
|
||||
fz_drop_document(ctx, copy);
|
||||
pdf_drop_verifier(ctx, verifier);
|
||||
pdf_drop_signer(ctx, signer);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Signed fields under `field`; with `drop`, their values go. */
|
||||
static int signed_under(fz_context *ctx, pdf_obj *field, int drop)
|
||||
{
|
||||
pdf_obj *kids = pdf_dict_get(ctx, field, PDF_NAME(Kids));
|
||||
int i, n = 0;
|
||||
if (pdf_mark_obj(ctx, field))
|
||||
return 0;
|
||||
fz_try(ctx)
|
||||
{
|
||||
if (pdf_name_eq(ctx, pdf_dict_get_inheritable(ctx, field, PDF_NAME(FT)), PDF_NAME(Sig)) &&
|
||||
pdf_is_dict(ctx, pdf_dict_get(ctx, field, PDF_NAME(V))))
|
||||
{
|
||||
n++;
|
||||
if (drop)
|
||||
{
|
||||
/* The drawn signature would still claim it. */
|
||||
pdf_dict_del(ctx, field, PDF_NAME(V));
|
||||
pdf_dict_del(ctx, field, PDF_NAME(AP));
|
||||
/* Its own lock went with it; it can sign again. */
|
||||
pdf_dict_put_int(ctx, field, PDF_NAME(Ff), pdf_dict_get_int(ctx, field, PDF_NAME(Ff)) & ~PDF_FIELD_IS_READ_ONLY);
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
pdf_dict_del(ctx, pdf_array_get(ctx, kids, i), PDF_NAME(AP));
|
||||
}
|
||||
}
|
||||
for (i = 0; i < pdf_array_len(ctx, kids); i++)
|
||||
n += signed_under(ctx, pdf_array_get(ctx, kids, i), drop);
|
||||
}
|
||||
fz_always(ctx)
|
||||
pdf_unmark_obj(ctx, field);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* SigFlags bit 2; MuPDF keeps its name private. */
|
||||
enum { APPEND_ONLY = 2 };
|
||||
|
||||
static int signed_fields(fz_context *ctx, pdf_document *pdf, int drop)
|
||||
{
|
||||
pdf_obj *form = pdf_dict_getp(ctx, pdf_trailer(ctx, pdf), "Root/AcroForm");
|
||||
pdf_obj *fields = pdf_dict_get(ctx, form, PDF_NAME(Fields));
|
||||
int i, n = 0;
|
||||
for (i = 0; i < pdf_array_len(ctx, fields); i++)
|
||||
n += signed_under(ctx, pdf_array_get(ctx, fields, i), drop);
|
||||
if (drop && n)
|
||||
pdf_dict_put_int(ctx, form, PDF_NAME(SigFlags), pdf_dict_get_int(ctx, form, PDF_NAME(SigFlags)) & ~APPEND_ONLY);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Ely's whole-file writes break signatures, so carry none. */
|
||||
void ely_unsign(fz_context *ctx, pdf_document *pdf)
|
||||
{
|
||||
signed_fields(ctx, pdf, 1);
|
||||
}
|
||||
|
||||
/* How many signed fields the form holds. */
|
||||
int ely_signed(fz_context *ctx, fz_document *doc)
|
||||
{
|
||||
int n = 0;
|
||||
fz_try(ctx)
|
||||
n = signed_fields(ctx, ely_pdf_of(ctx, doc), 0);
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* Text blocks: list, take out glyphs, write new text (PDF-03). */
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* The page's text, read the same way every time. */
|
||||
static fz_stext_page *read_text(fz_context *ctx, fz_document *doc, int number)
|
||||
{
|
||||
fz_stext_options opts = { 0 };
|
||||
return fz_new_stext_page_from_page_number(ctx, doc, number, &opts);
|
||||
}
|
||||
|
||||
/* The `index`th text block, or NULL. */
|
||||
static fz_stext_block *nth_block(fz_stext_page *text, int index)
|
||||
{
|
||||
fz_stext_block *block;
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
if (block->type == FZ_STEXT_BLOCK_TEXT && index-- == 0)
|
||||
return block;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Glyph origins to take out, in user space. */
|
||||
typedef struct
|
||||
{
|
||||
fz_point *at;
|
||||
int *c;
|
||||
int n;
|
||||
int hit;
|
||||
} targets;
|
||||
|
||||
/* The glyphs of `block` with their user-space origins. */
|
||||
static void block_targets(fz_context *ctx, fz_stext_block *block, fz_matrix to_user, targets *t)
|
||||
{
|
||||
fz_stext_line *line;
|
||||
fz_stext_char *ch;
|
||||
int n = 0;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
n++;
|
||||
t->at = fz_malloc_array(ctx, n + 1, fz_point);
|
||||
t->c = fz_malloc_array(ctx, n + 1, int);
|
||||
t->n = t->hit = 0;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
if (!(ch->flags & FZ_STEXT_SYNTHETIC))
|
||||
{
|
||||
t->at[t->n] = fz_transform_point(ch->origin, to_user);
|
||||
t->c[t->n++] = ch->c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Removes a glyph drawn at one of the targets. */
|
||||
static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_matrix trm, fz_matrix ctm,
|
||||
fz_rect bbox, int tr, float ca, float CA)
|
||||
{
|
||||
targets *t = opaque;
|
||||
fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm);
|
||||
int i, k;
|
||||
for (i = 0; i < t->n; i++)
|
||||
{
|
||||
if (fabsf(at.x - t->at[i].x) > 0.5f || fabsf(at.y - t->at[i].y) > 0.5f)
|
||||
continue;
|
||||
for (k = 0; k < ucslen; k++)
|
||||
if (ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER)
|
||||
{
|
||||
t->hit++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Runs the glyph filter over the page; `dry` changes nothing. */
|
||||
static void filter_glyphs(fz_context *ctx, pdf_document *pdf, pdf_page *page, targets *t, int dry)
|
||||
{
|
||||
pdf_sanitize_filter_options sopts = { 0 };
|
||||
pdf_filter_factory list[2] = { { pdf_new_sanitize_filter, NULL }, { NULL, NULL } };
|
||||
pdf_filter_options opts = { 0 };
|
||||
sopts.opaque = t;
|
||||
sopts.text_filter = take_glyph;
|
||||
list[0].options = &sopts;
|
||||
opts.filters = list;
|
||||
opts.no_update = dry;
|
||||
t->hit = 0;
|
||||
pdf_filter_page_contents(ctx, pdf, page, &opts);
|
||||
}
|
||||
|
||||
/* User space from the page space MuPDF reports in. */
|
||||
fz_matrix ely_to_user(fz_context *ctx, pdf_page *page)
|
||||
{
|
||||
fz_matrix ctm;
|
||||
pdf_page_transform(ctx, page, NULL, &ctm);
|
||||
return fz_invert_matrix(ctm);
|
||||
}
|
||||
|
||||
/* Whether all of `block` sits in the page's own content. */
|
||||
static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_block *block)
|
||||
{
|
||||
targets t = { NULL, NULL, 0, 0 };
|
||||
int ok = 0;
|
||||
fz_var(t.at);
|
||||
fz_var(t.c);
|
||||
pdf_begin_operation(ctx, pdf, "Look at text");
|
||||
fz_try(ctx)
|
||||
{
|
||||
block_targets(ctx, block, ely_to_user(ctx, page), &t);
|
||||
filter_glyphs(ctx, pdf, page, &t, 1);
|
||||
ok = t.n > 0 && t.hit == t.n;
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_abandon_operation(ctx, pdf);
|
||||
ely_forget_resources(ctx, pdf);
|
||||
fz_free(ctx, t.at);
|
||||
fz_free(ctx, t.c);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* The text of `block`, lines on lines. */
|
||||
static void block_text(fz_context *ctx, fz_buffer *buf, fz_stext_block *block)
|
||||
{
|
||||
fz_stext_line *line;
|
||||
fz_stext_char *ch;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
{
|
||||
if (line != block->u.t.first_line)
|
||||
fz_append_byte(ctx, buf, '\n');
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
fz_append_rune(ctx, buf, ch->c);
|
||||
}
|
||||
}
|
||||
|
||||
/* Horizontal lines only; turned text is not edited. */
|
||||
static int upright(fz_stext_block *block)
|
||||
{
|
||||
fz_stext_line *line;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
if (line->wmode || fabsf(line->dir.x - 1) > 0.01f)
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* JSON of the page's text blocks. */
|
||||
int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char **data, size_t *len)
|
||||
{
|
||||
fz_stext_page *text = NULL;
|
||||
pdf_page *page = NULL;
|
||||
fz_buffer *buf = NULL, *words = NULL;
|
||||
fz_output *out = NULL;
|
||||
fz_var(text);
|
||||
fz_var(page);
|
||||
fz_var(buf);
|
||||
fz_var(words);
|
||||
fz_var(out);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
fz_stext_block *block;
|
||||
int first = 1;
|
||||
text = read_text(ctx, doc, number);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_string(ctx, out, "[");
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
{
|
||||
fz_stext_char *ch;
|
||||
int editable;
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
ch = block->u.t.first_line->first_char;
|
||||
editable = upright(block) && reachable(ctx, pdf, page, block);
|
||||
fz_drop_buffer(ctx, words);
|
||||
words = NULL;
|
||||
words = fz_new_buffer(ctx, 256);
|
||||
block_text(ctx, words, block);
|
||||
fz_terminate_buffer(ctx, words);
|
||||
fz_write_printf(ctx, out, "%s{\"rect\":[%g,%g,%g,%g],\"text\":%q,\"font\":%q,\"size\":%g,\"color\":%d,\"editable\":%s}",
|
||||
first ? "" : ",", block->bbox.x0, block->bbox.y0, block->bbox.x1, block->bbox.y1,
|
||||
fz_string_from_buffer(ctx, words), fz_font_name(ctx, ch->font), ch->size, (int)(ch->argb & 0xffffff),
|
||||
editable ? "true" : "false");
|
||||
first = 0;
|
||||
}
|
||||
fz_write_string(ctx, out, "]");
|
||||
fz_close_output(ctx, out);
|
||||
*len = fz_buffer_extract(ctx, buf, data);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_drop_buffer(ctx, words);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Distinct baselines, and the gap between the first two. */
|
||||
static void baselines(fz_stext_block *block, ely_shape *shape)
|
||||
{
|
||||
fz_stext_line *line;
|
||||
float last = block->u.t.first_line->first_char->origin.y;
|
||||
shape->lines = 1;
|
||||
shape->gap = 0;
|
||||
for (line = block->u.t.first_line->next; line; line = line->next)
|
||||
{
|
||||
float y = line->first_char->origin.y;
|
||||
if (fabsf(y - last) <= 0.5f)
|
||||
continue;
|
||||
if (shape->lines == 1)
|
||||
shape->gap = y - shape->first.y;
|
||||
shape->lines++;
|
||||
last = y;
|
||||
}
|
||||
/* Runs on one baseline: an ordinary line gap. */
|
||||
if (shape->gap <= 0)
|
||||
shape->gap = shape->size * 1.2f;
|
||||
}
|
||||
|
||||
/* Reads block `index` and checks it; nothing changes. */
|
||||
void ely_read_block(fz_context *ctx, fz_document *doc, pdf_page *page, int number, int index, const char *expected,
|
||||
ely_shape *shape)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
fz_stext_page *text = NULL;
|
||||
fz_buffer *words = NULL;
|
||||
targets *t = fz_malloc_struct(ctx, targets);
|
||||
shape->glyphs = t;
|
||||
fz_var(text);
|
||||
fz_var(words);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_stext_block *block;
|
||||
fz_stext_char *ch;
|
||||
text = read_text(ctx, doc, number);
|
||||
block = nth_block(text, index);
|
||||
if (!block)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no text block %d", index);
|
||||
words = fz_new_buffer(ctx, 256);
|
||||
block_text(ctx, words, block);
|
||||
fz_terminate_buffer(ctx, words);
|
||||
if (strcmp(fz_string_from_buffer(ctx, words), expected))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the text changed since it was read");
|
||||
if (!upright(block) || !reachable(ctx, pdf, page, block))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "this text is not in the page's own content");
|
||||
block_targets(ctx, block, ely_to_user(ctx, page), t);
|
||||
ch = block->u.t.first_line->first_char;
|
||||
shape->font = fz_keep_font(ctx, ch->font);
|
||||
shape->first = ch->origin;
|
||||
shape->size = ch->size;
|
||||
shape->rgb = (int)(ch->argb & 0xffffff);
|
||||
shape->bbox = block->bbox;
|
||||
baselines(block, shape);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, words);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Takes out the glyphs `ely_read_block` found. */
|
||||
void ely_remove_block(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_shape *shape)
|
||||
{
|
||||
targets *t = shape->glyphs;
|
||||
filter_glyphs(ctx, pdf, page, t, 0);
|
||||
if (t->hit != t->n)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "%d of %d glyphs were found", t->hit, t->n);
|
||||
}
|
||||
|
||||
void ely_drop_shape(fz_context *ctx, ely_shape *shape)
|
||||
{
|
||||
targets *t = shape->glyphs;
|
||||
fz_drop_font(ctx, shape->font);
|
||||
if (t)
|
||||
{
|
||||
fz_free(ctx, t->at);
|
||||
fz_free(ctx, t->c);
|
||||
fz_free(ctx, t);
|
||||
}
|
||||
shape->font = NULL;
|
||||
shape->glyphs = NULL;
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/* New text on a page: a pen, lines, content (PDF-03). */
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
typedef enum
|
||||
{
|
||||
PEN_CID,
|
||||
PEN_SIMPLE,
|
||||
PEN_CJK
|
||||
} pen_kind;
|
||||
|
||||
/* A font to write with and its resource. */
|
||||
typedef struct
|
||||
{
|
||||
pen_kind kind;
|
||||
fz_font *font;
|
||||
int ordering;
|
||||
pdf_obj *ref;
|
||||
} pen;
|
||||
|
||||
/* What to write, where, in what. */
|
||||
typedef struct
|
||||
{
|
||||
const char *text;
|
||||
const char *font;
|
||||
float size;
|
||||
int rgb;
|
||||
/* Page space: left, top, width. */
|
||||
float box[3];
|
||||
} text_args;
|
||||
|
||||
/* The script face for typed signatures, from the worker. */
|
||||
static const unsigned char *script_data;
|
||||
static size_t script_len;
|
||||
|
||||
void ely_script_font(const unsigned char *data, size_t len)
|
||||
{
|
||||
script_data = data;
|
||||
script_len = len;
|
||||
}
|
||||
|
||||
/* The pen named `choice`; "original" writes in `own`. */
|
||||
static void choose_pen(fz_context *ctx, const char *choice, fz_font *own, pen *p)
|
||||
{
|
||||
static const struct { const char *choice, *name; } latin[] = {
|
||||
{ "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } };
|
||||
static const struct { const char *choice; int ordering; } cjk[] = {
|
||||
{ "cjk-sc", FZ_ADOBE_GB }, { "cjk-tc", FZ_ADOBE_CNS }, { "cjk-jp", FZ_ADOBE_JAPAN }, { "cjk-kr", FZ_ADOBE_KOREA } };
|
||||
size_t i;
|
||||
if (!strcmp(choice, "script"))
|
||||
{
|
||||
if (!script_data)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given");
|
||||
p->kind = PEN_CID;
|
||||
p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0);
|
||||
return;
|
||||
}
|
||||
if (!strcmp(choice, "original"))
|
||||
{
|
||||
if (!own || !pdf_font_writing_supported(ctx, own))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font cannot write new text");
|
||||
p->kind = PEN_CID;
|
||||
p->font = fz_keep_font(ctx, own);
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < nelem(latin); i++)
|
||||
if (!strcmp(choice, latin[i].choice))
|
||||
{
|
||||
p->kind = PEN_SIMPLE;
|
||||
p->font = fz_new_base14_font(ctx, latin[i].name);
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < nelem(cjk); i++)
|
||||
if (!strcmp(choice, cjk[i].choice))
|
||||
{
|
||||
p->kind = PEN_CJK;
|
||||
p->ordering = cjk[i].ordering;
|
||||
p->font = fz_new_cjk_font(ctx, cjk[i].ordering);
|
||||
return;
|
||||
}
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice);
|
||||
}
|
||||
|
||||
/* The pen's font as a resource of `pdf`. */
|
||||
static void add_pen(fz_context *ctx, pdf_document *pdf, pen *p)
|
||||
{
|
||||
switch (p->kind)
|
||||
{
|
||||
case PEN_CID:
|
||||
p->ref = pdf_add_cid_font(ctx, pdf, p->font);
|
||||
break;
|
||||
case PEN_SIMPLE:
|
||||
p->ref = pdf_add_simple_font(ctx, pdf, p->font, PDF_SIMPLE_ENCODING_LATIN);
|
||||
break;
|
||||
case PEN_CJK:
|
||||
p->ref = pdf_add_cjk_font(ctx, pdf, p->font, p->ordering, 0, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* The pen's glyph for `u`; 0 when it has none. */
|
||||
static int glyph(fz_context *ctx, pen *p, int u)
|
||||
{
|
||||
if (p->kind == PEN_SIMPLE && fz_windows_1252_from_unicode(u) < 0)
|
||||
return 0;
|
||||
return fz_encode_character(ctx, p->font, u);
|
||||
}
|
||||
|
||||
/* `u` as the pen's code, in hex. */
|
||||
static void code(fz_context *ctx, fz_buffer *buf, pen *p, int u, int gid)
|
||||
{
|
||||
switch (p->kind)
|
||||
{
|
||||
case PEN_CID:
|
||||
fz_append_printf(ctx, buf, "%04x", gid);
|
||||
break;
|
||||
case PEN_SIMPLE:
|
||||
fz_append_printf(ctx, buf, "%02x", fz_windows_1252_from_unicode(u));
|
||||
break;
|
||||
case PEN_CJK:
|
||||
if (u > 0xffff)
|
||||
fz_append_printf(ctx, buf, "%04x%04x", 0xd800 + ((u - 0x10000) >> 10), 0xdc00 + ((u - 0x10000) & 0x3ff));
|
||||
else
|
||||
fz_append_printf(ctx, buf, "%04x", u);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* `text` as code points; the count is returned. */
|
||||
static int runes(fz_context *ctx, const char *text, int **out)
|
||||
{
|
||||
int n = 0, c;
|
||||
const char *s;
|
||||
for (s = text; *s; n++)
|
||||
s += fz_chartorune(&c, s);
|
||||
*out = fz_malloc_array(ctx, n + 1, int);
|
||||
for (n = 0, s = text; *s; n++)
|
||||
s += fz_chartorune(&(*out)[n], s);
|
||||
return n;
|
||||
}
|
||||
|
||||
static float advance(fz_context *ctx, pen *p, int u, float size)
|
||||
{
|
||||
return fz_advance_glyph(ctx, p->font, glyph(ctx, p, u), 0) * size;
|
||||
}
|
||||
|
||||
/* Lines no wider than `width`: [from, to) each. */
|
||||
static int lay_out(fz_context *ctx, pen *p, const int *u, int n, float size, float width, int *from, int *to,
|
||||
float *widest)
|
||||
{
|
||||
int i = 0, lines = 0;
|
||||
*widest = 0;
|
||||
while (i <= n)
|
||||
{
|
||||
int j, space = -1;
|
||||
float run = 0, before = 0;
|
||||
for (j = i; j < n && u[j] != '\n'; j++)
|
||||
{
|
||||
float w = advance(ctx, p, u[j], size);
|
||||
if (run + w > width && j > i)
|
||||
break;
|
||||
if (u[j] == ' ')
|
||||
space = j, before = run;
|
||||
run += w;
|
||||
}
|
||||
from[lines] = i;
|
||||
if (j < n && u[j] != '\n' && space > i)
|
||||
{
|
||||
/* Too long for the line: wrap at a space. */
|
||||
to[lines++] = space;
|
||||
*widest = fz_max(*widest, before);
|
||||
i = space + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
to[lines++] = j;
|
||||
*widest = fz_max(*widest, run);
|
||||
i = j < n && u[j] != '\n' ? j : j + 1;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/* A resource name in `type` that is free, given `ref`. */
|
||||
const char *ely_add_resource(fz_context *ctx, pdf_document *pdf, pdf_obj *page, pdf_obj *type, const char *stem,
|
||||
pdf_obj *ref, char *name, size_t room)
|
||||
{
|
||||
pdf_obj *res = pdf_dict_get_inheritable(ctx, page, PDF_NAME(Resources));
|
||||
pdf_obj *dict;
|
||||
int n;
|
||||
if (!res)
|
||||
res = pdf_dict_put_dict(ctx, page, PDF_NAME(Resources), 2);
|
||||
dict = pdf_dict_get(ctx, res, type);
|
||||
if (!dict)
|
||||
dict = pdf_dict_put_dict(ctx, res, type, 2);
|
||||
for (n = 1;; n++)
|
||||
{
|
||||
fz_snprintf(name, room, "%s%d", stem, n);
|
||||
if (!pdf_dict_gets(ctx, dict, name))
|
||||
break;
|
||||
}
|
||||
pdf_dict_puts(ctx, dict, name, ref);
|
||||
return name;
|
||||
}
|
||||
|
||||
/* The marker of a page whose own content Ely wrapped. */
|
||||
static const char wrap[] = "q\n%Ely\n";
|
||||
|
||||
/* Adds `buf` after the page's content; the new stream. */
|
||||
pdf_obj *ely_append_content(fz_context *ctx, pdf_document *pdf, pdf_obj *page, fz_buffer *buf)
|
||||
{
|
||||
pdf_obj *contents = pdf_dict_get(ctx, page, PDF_NAME(Contents));
|
||||
pdf_obj *list, *first = pdf_is_array(ctx, contents) ? pdf_array_get(ctx, contents, 0) : NULL;
|
||||
fz_buffer *head = NULL, *mark = first ? pdf_load_stream(ctx, first) : NULL;
|
||||
pdf_obj *added = NULL;
|
||||
int wrapped;
|
||||
fz_var(head);
|
||||
fz_try(ctx)
|
||||
{
|
||||
wrapped = mark && mark->len == strlen(wrap) && !memcmp(mark->data, wrap, mark->len);
|
||||
if (wrapped)
|
||||
list = contents;
|
||||
else
|
||||
{
|
||||
list = pdf_new_array(ctx, pdf, 4);
|
||||
head = fz_new_buffer_from_copied_data(ctx, (const unsigned char *)wrap, strlen(wrap));
|
||||
pdf_array_push_drop(ctx, list, pdf_add_stream(ctx, pdf, head, NULL, 0));
|
||||
if (pdf_is_array(ctx, contents))
|
||||
{
|
||||
int i;
|
||||
for (i = 0; i < pdf_array_len(ctx, contents); i++)
|
||||
pdf_array_push(ctx, list, pdf_array_get(ctx, contents, i));
|
||||
}
|
||||
else if (contents)
|
||||
pdf_array_push(ctx, list, contents);
|
||||
fz_drop_buffer(ctx, head);
|
||||
head = fz_new_buffer_from_copied_data(ctx, (const unsigned char *)"Q\n", 2);
|
||||
pdf_array_push_drop(ctx, list, pdf_add_stream(ctx, pdf, head, NULL, 0));
|
||||
pdf_dict_put_drop(ctx, page, PDF_NAME(Contents), list);
|
||||
}
|
||||
added = pdf_add_stream(ctx, pdf, buf, NULL, 0);
|
||||
pdf_array_push_drop(ctx, list, added);
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
fz_drop_buffer(ctx, head);
|
||||
fz_drop_buffer(ctx, mark);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
return added;
|
||||
}
|
||||
|
||||
/* Text laid out: the pen, code points, lines, placement. */
|
||||
typedef struct
|
||||
{
|
||||
pen p;
|
||||
int *u, n;
|
||||
int *from, *to, lines;
|
||||
float widest;
|
||||
fz_point first;
|
||||
float gap;
|
||||
} laid;
|
||||
|
||||
static void drop_laid(fz_context *ctx, laid *l)
|
||||
{
|
||||
fz_drop_font(ctx, l->p.font);
|
||||
pdf_drop_obj(ctx, l->p.ref);
|
||||
fz_free(ctx, l->u);
|
||||
fz_free(ctx, l->from);
|
||||
fz_free(ctx, l->to);
|
||||
}
|
||||
|
||||
/* Lays `a` out from `first`, or names a missing glyph. */
|
||||
static int lay_text(fz_context *ctx, text_args *a, fz_font *own, fz_point first, float gap, laid *l)
|
||||
{
|
||||
int i;
|
||||
choose_pen(ctx, a->font, own, &l->p);
|
||||
l->n = runes(ctx, a->text, &l->u);
|
||||
for (i = 0; i < l->n; i++)
|
||||
if (l->u[i] != '\n' && !glyph(ctx, &l->p, l->u[i]))
|
||||
return l->u[i];
|
||||
l->from = fz_malloc_array(ctx, l->n + 2, int);
|
||||
l->to = fz_malloc_array(ctx, l->n + 2, int);
|
||||
l->lines = lay_out(ctx, &l->p, l->u, l->n, a->size, a->box[2], l->from, l->to, &l->widest);
|
||||
l->first = first;
|
||||
l->gap = gap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The area laid text covers, in page space. */
|
||||
static fz_rect laid_area(laid *l, float size)
|
||||
{
|
||||
return fz_make_rect(l->first.x, l->first.y - size, l->first.x + l->widest, l->first.y + (l->lines - 1) * l->gap + size * 0.3f);
|
||||
}
|
||||
|
||||
/* Writes laid text onto `page` of `pdf`. */
|
||||
static void draw_laid(fz_context *ctx, pdf_document *pdf, pdf_page *page, laid *l, text_args *a)
|
||||
{
|
||||
fz_buffer *buf = NULL;
|
||||
fz_var(buf);
|
||||
fz_try(ctx)
|
||||
{
|
||||
fz_matrix to_user = ely_to_user(ctx, page);
|
||||
char name[32];
|
||||
int i, line;
|
||||
pdf_drop_obj(ctx, l->p.ref);
|
||||
l->p.ref = NULL;
|
||||
add_pen(ctx, pdf, &l->p);
|
||||
ely_add_resource(ctx, pdf, page->obj, PDF_NAME(Font), "ElyF", l->p.ref, name, sizeof name);
|
||||
buf = fz_new_buffer(ctx, 256);
|
||||
fz_append_string(ctx, buf, "q\n");
|
||||
for (line = 0; line < l->lines; line++)
|
||||
{
|
||||
fz_matrix tm = fz_concat(fz_make_matrix(1, 0, 0, -1, l->first.x, l->first.y + line * l->gap), to_user);
|
||||
fz_append_printf(ctx, buf, "BT /%s %g Tf %g %g %g rg %g %g %g %g %g %g Tm <", name, a->size,
|
||||
((a->rgb >> 16) & 255) / 255.0f, ((a->rgb >> 8) & 255) / 255.0f, (a->rgb & 255) / 255.0f,
|
||||
tm.a, tm.b, tm.c, tm.d, tm.e, tm.f);
|
||||
for (i = l->from[line]; i < l->to[line]; i++)
|
||||
code(ctx, buf, &l->p, l->u[i], glyph(ctx, &l->p, l->u[i]));
|
||||
fz_append_string(ctx, buf, "> Tj ET\n");
|
||||
}
|
||||
fz_append_string(ctx, buf, "Q\n");
|
||||
ely_append_content(ctx, pdf, page->obj, buf);
|
||||
}
|
||||
fz_always(ctx)
|
||||
fz_drop_buffer(ctx, buf);
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Edits a snapshot copy and draws `region`; the document stays. */
|
||||
static void preview(fz_context *ctx, pdf_document *pdf, int number, int block, const char *expected, text_args *a,
|
||||
laid *l, const char *password, fz_rect region, float scale, unsigned char **pixels, int *w, int *h)
|
||||
{
|
||||
fz_buffer *buf = fz_new_buffer(ctx, 4096);
|
||||
fz_output *out = NULL;
|
||||
fz_document *copy = NULL;
|
||||
pdf_page *page = NULL;
|
||||
ely_shape shape = { NULL };
|
||||
fz_var(out);
|
||||
fz_var(copy);
|
||||
fz_var(page);
|
||||
fz_try(ctx)
|
||||
{
|
||||
int tile[4];
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
pdf_write_snapshot(ctx, pdf, out);
|
||||
fz_close_output(ctx, out);
|
||||
copy = fz_open_document_with_buffer(ctx, "application/pdf", buf);
|
||||
if (fz_needs_password(ctx, copy) && !fz_authenticate_password(ctx, copy, password))
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the copy refused its password");
|
||||
page = pdf_load_page(ctx, ely_pdf_of(ctx, copy), number);
|
||||
if (block >= 0)
|
||||
{
|
||||
ely_read_block(ctx, copy, page, number, block, expected, &shape);
|
||||
ely_remove_block(ctx, ely_pdf_of(ctx, copy), page, &shape);
|
||||
}
|
||||
draw_laid(ctx, ely_pdf_of(ctx, copy), page, l, a);
|
||||
region = fz_expand_rect(region, 8);
|
||||
tile[0] = (int)floorf(region.x0 * scale), tile[1] = (int)floorf(region.y0 * scale);
|
||||
tile[2] = (int)ceilf(region.x1 * scale) - tile[0], tile[3] = (int)ceilf(region.y1 * scale) - tile[1];
|
||||
*pixels = fz_malloc(ctx, (size_t)tile[2] * tile[3] * 4);
|
||||
if (ely_render(ctx, copy, number, scale, 0, tile, 0, *pixels) < 0)
|
||||
fz_throw(ctx, FZ_ERROR_GENERIC, "%s", ely_error());
|
||||
*w = tile[2], *h = tile[3];
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
ely_drop_shape(ctx, &shape);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_drop_document(ctx, copy);
|
||||
fz_drop_output(ctx, out);
|
||||
fz_drop_buffer(ctx, buf);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Edits, adds or previews text: 0, 2 missing, -1 failed. */
|
||||
int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const char *expected, const char *text,
|
||||
const char *font, float size, int rgb, const float box[3], const char *password, float scale, int *missing,
|
||||
int *grew, unsigned char **pixels, int *w, int *h)
|
||||
{
|
||||
pdf_document *pdf = NULL;
|
||||
pdf_page *page = NULL;
|
||||
ely_shape shape = { NULL };
|
||||
laid l = { { PEN_SIMPLE, NULL, 0, NULL } };
|
||||
int begun = 0;
|
||||
fz_var(pdf);
|
||||
fz_var(page);
|
||||
fz_var(begun);
|
||||
*pixels = NULL;
|
||||
*grew = 0;
|
||||
fz_try(ctx)
|
||||
{
|
||||
text_args a = { text, font, size, rgb, { box[0], box[1], box[2] } };
|
||||
fz_point first = fz_make_point(box[0], box[1] + size * 0.8f);
|
||||
float gap = size * 1.2f;
|
||||
pdf = ely_pdf_of(ctx, doc);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
/* Checks precede the step, so redo survives a refusal. */
|
||||
if (block >= 0)
|
||||
{
|
||||
ely_read_block(ctx, doc, page, number, block, expected, &shape);
|
||||
first = shape.first;
|
||||
gap = shape.gap;
|
||||
}
|
||||
*missing = lay_text(ctx, &a, shape.font, first, gap, &l);
|
||||
if (!*missing)
|
||||
{
|
||||
fz_rect drawn = laid_area(&l, size), region = drawn;
|
||||
if (block >= 0)
|
||||
{
|
||||
fz_rect room = fz_expand_rect(shape.bbox, 1);
|
||||
region = fz_union_rect(shape.bbox, drawn);
|
||||
*grew = fabsf(size - shape.size) > 0.01f || !fz_contains_rect(room, drawn);
|
||||
}
|
||||
if (scale > 0)
|
||||
preview(ctx, pdf, number, block, expected, &a, &l, password, region, scale, pixels, w, h);
|
||||
else
|
||||
{
|
||||
pdf_begin_operation(ctx, pdf, block >= 0 ? "Edit text" : "Add text");
|
||||
begun = 1;
|
||||
if (block >= 0)
|
||||
ely_remove_block(ctx, pdf, page, &shape);
|
||||
draw_laid(ctx, pdf, page, &l, &a);
|
||||
pdf_end_operation(ctx, pdf);
|
||||
begun = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
drop_laid(ctx, &l);
|
||||
ely_drop_shape(ctx, &shape);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
{
|
||||
fz_free(ctx, *pixels);
|
||||
*pixels = NULL;
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
}
|
||||
return *missing ? 2 : 0;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The shim's text and object calls (PDF-03, PDF-04).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io, ptr,
|
||||
};
|
||||
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_text_blocks(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_objects(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_put_text(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
page: c_int,
|
||||
block: c_int,
|
||||
expected: *const c_char,
|
||||
text: *const c_char,
|
||||
font: *const c_char,
|
||||
size: f32,
|
||||
rgb: c_int,
|
||||
bx: *const f32,
|
||||
password: *const c_char,
|
||||
scale: f32,
|
||||
missing: *mut c_int,
|
||||
grew: *mut c_int,
|
||||
pixels: *mut *mut u8,
|
||||
w: *mut c_int,
|
||||
h: *mut c_int,
|
||||
) -> c_int;
|
||||
fn ely_place(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
page: c_int,
|
||||
index: c_int,
|
||||
t: *const f32,
|
||||
drop: c_int,
|
||||
path: *const c_char,
|
||||
) -> c_int;
|
||||
fn ely_add_image(ctx: *mut Context, doc: *mut Document, page: c_int, path: *const c_char, rect: *const f32) -> c_int;
|
||||
}
|
||||
|
||||
/// New text and where it goes.
|
||||
pub struct Writing {
|
||||
/// The block replaced, or none for new text.
|
||||
pub block: Option<usize>,
|
||||
pub expected: String,
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub size: f32,
|
||||
pub rgb: u32,
|
||||
/// Page space: left, top, width.
|
||||
pub bx: [f32; 3],
|
||||
}
|
||||
|
||||
/// What a text write did.
|
||||
pub enum Wrote {
|
||||
Done,
|
||||
/// A character the font cannot draw; nothing changed.
|
||||
Missing(char),
|
||||
/// The would-be result: BGRA, size, and whether it grew.
|
||||
Preview(Vec<u8>, (u32, u32), bool),
|
||||
}
|
||||
|
||||
fn number(value: usize) -> io::Result<c_int> {
|
||||
c_int::try_from(value).map_err(|_| io::Error::other("a number too large"))
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
pub fn text_blocks(&self, doc: &Open, at: usize) -> io::Result<Vec<u8>> {
|
||||
let at = page(at)?;
|
||||
self.owned(|data, len| unsafe { ely_text_blocks(self.ctx, doc.0, at, data, len) })
|
||||
}
|
||||
|
||||
pub fn objects(&self, doc: &Open, at: usize) -> io::Result<Vec<u8>> {
|
||||
let at = page(at)?;
|
||||
self.owned(|data, len| unsafe { ely_objects(self.ctx, doc.0, at, data, len) })
|
||||
}
|
||||
|
||||
/// Writes `w` on page `at`; a positive `scale` only previews.
|
||||
pub fn put_text(&self, doc: &Open, at: usize, w: &Writing, password: &str, scale: f32) -> io::Result<Wrote> {
|
||||
let block = w.block.map_or(Ok(-1), number)?;
|
||||
let (expected, words, font) = (text(&w.expected)?, text(&w.text)?, text(&w.font)?);
|
||||
let password = text(password)?;
|
||||
let rgb = c_int::try_from(w.rgb & 0xff_ffff).map_err(io::Error::other)?;
|
||||
let (mut missing, mut grew, mut pixels, mut width, mut height) = (0, 0, ptr::null_mut(), 0, 0);
|
||||
let status = checked(unsafe {
|
||||
ely_put_text(
|
||||
self.ctx,
|
||||
doc.0,
|
||||
page(at)?,
|
||||
block,
|
||||
expected.as_ptr(),
|
||||
words.as_ptr(),
|
||||
font.as_ptr(),
|
||||
w.size,
|
||||
rgb,
|
||||
w.bx.as_ptr(),
|
||||
password.as_ptr(),
|
||||
scale,
|
||||
&mut missing,
|
||||
&mut grew,
|
||||
&mut pixels,
|
||||
&mut width,
|
||||
&mut height,
|
||||
)
|
||||
})?;
|
||||
if status == 2 {
|
||||
let missing = u32::try_from(missing).ok().and_then(char::from_u32);
|
||||
return Ok(Wrote::Missing(missing.ok_or_else(|| io::Error::other("a missing glyph out of range"))?));
|
||||
}
|
||||
if scale <= 0.0 {
|
||||
return Ok(Wrote::Done);
|
||||
}
|
||||
let size = (u32::try_from(width).map_err(io::Error::other)?, u32::try_from(height).map_err(io::Error::other)?);
|
||||
let bytes = unsafe { std::slice::from_raw_parts(pixels, size.0 as usize * size.1 as usize * 4) }.to_vec();
|
||||
self.free(pixels);
|
||||
Ok(Wrote::Preview(bytes, size, grew != 0))
|
||||
}
|
||||
|
||||
/// Moves draw `index` by page-space `t`, swaps or drops it.
|
||||
pub fn place(&self, doc: &Open, at: usize, index: usize, t: [f32; 6], drop: bool, swap: Option<&str>) -> io::Result<()> {
|
||||
let swap = swap.map(text).transpose()?;
|
||||
let path = swap.as_ref().map_or(ptr::null(), |path| path.as_ptr());
|
||||
let index = number(index)?;
|
||||
checked(unsafe { ely_place(self.ctx, doc.0, page(at)?, index, t.as_ptr(), c_int::from(drop), path) }).map(|_| ())
|
||||
}
|
||||
|
||||
/// Draws the image at `path` into page-space `rect`.
|
||||
pub fn add_image(&self, doc: &Open, at: usize, path: &str, rect: [f32; 4]) -> io::Result<()> {
|
||||
let path = text(path)?;
|
||||
checked(unsafe { ely_add_image(self.ctx, doc.0, page(at)?, path.as_ptr(), rect.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! The shim's page edits, undo and saves.
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_journal(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_undo(ctx: *mut Context, doc: *mut Document, redo: c_int) -> c_int;
|
||||
fn ely_state(ctx: *mut Context, doc: *mut Document, at: *mut c_int, count: *mut c_int) -> c_int;
|
||||
fn ely_delete_pages(ctx: *mut Context, doc: *mut Document, pages: *const c_int, n: c_int) -> c_int;
|
||||
fn ely_move_pages(ctx: *mut Context, doc: *mut Document, pages: *const c_int, n: c_int, to: c_int) -> c_int;
|
||||
fn ely_rotate_pages(ctx: *mut Context, doc: *mut Document, pages: *const c_int, n: c_int, by: c_int) -> c_int;
|
||||
fn ely_crop_pages(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
pages: *const c_int,
|
||||
n: c_int,
|
||||
margins: *const f32,
|
||||
) -> c_int;
|
||||
fn ely_insert_blank(ctx: *mut Context, doc: *mut Document, at: c_int, w: f32, h: f32) -> c_int;
|
||||
fn ely_insert_file(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
at: c_int,
|
||||
path: *const c_char,
|
||||
count: *mut c_int,
|
||||
) -> c_int;
|
||||
fn ely_save(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
raw: *const c_char,
|
||||
out: *const c_char,
|
||||
password: *const c_char,
|
||||
) -> c_int;
|
||||
fn ely_extract(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
pages: *const c_int,
|
||||
n: c_int,
|
||||
raw: *const c_char,
|
||||
out: *const c_char,
|
||||
password: *const c_char,
|
||||
) -> c_int;
|
||||
}
|
||||
|
||||
/// Page numbers as the shim takes them.
|
||||
fn numbers(pages: &[usize]) -> io::Result<(Vec<c_int>, c_int)> {
|
||||
let pages = pages.iter().map(|at| page(*at)).collect::<io::Result<Vec<_>>>()?;
|
||||
let n = c_int::try_from(pages.len()).map_err(|_| io::Error::other("too many pages"))?;
|
||||
Ok((pages, n))
|
||||
}
|
||||
|
||||
/// The scratch copy a save writes first, removed after.
|
||||
pub(crate) fn raw(out: &Path) -> std::path::PathBuf {
|
||||
let mut name = out.as_os_str().to_owned();
|
||||
name.push(".raw");
|
||||
name.into()
|
||||
}
|
||||
|
||||
pub(crate) fn utf8(path: &Path) -> io::Result<&str> {
|
||||
path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
/// Starts the undo history; every edit becomes a step.
|
||||
pub fn journal(&self, doc: &Open) -> io::Result<()> {
|
||||
checked(unsafe { ely_journal(self.ctx, doc.0) }).map(|_| ())
|
||||
}
|
||||
|
||||
/// MuPDF's undo position and step count.
|
||||
pub fn state(&self, doc: &Open) -> io::Result<(usize, usize)> {
|
||||
let (mut at, mut count) = (0, 0);
|
||||
checked(unsafe { ely_state(self.ctx, doc.0, &mut at, &mut count) })?;
|
||||
let size = |n: c_int| usize::try_from(n).map_err(io::Error::other);
|
||||
Ok((size(at)?, size(count)?))
|
||||
}
|
||||
|
||||
pub fn undo(&self, doc: &Open, redo: bool) -> io::Result<()> {
|
||||
checked(unsafe { ely_undo(self.ctx, doc.0, c_int::from(redo)) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn delete_pages(&self, doc: &Open, pages: &[usize]) -> io::Result<()> {
|
||||
let (pages, n) = numbers(pages)?;
|
||||
checked(unsafe { ely_delete_pages(self.ctx, doc.0, pages.as_ptr(), n) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn move_pages(&self, doc: &Open, pages: &[usize], to: usize) -> io::Result<()> {
|
||||
let (pages, n) = numbers(pages)?;
|
||||
checked(unsafe { ely_move_pages(self.ctx, doc.0, pages.as_ptr(), n, page(to)?) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn rotate_pages(&self, doc: &Open, pages: &[usize], by: i32) -> io::Result<()> {
|
||||
let (pages, n) = numbers(pages)?;
|
||||
checked(unsafe { ely_rotate_pages(self.ctx, doc.0, pages.as_ptr(), n, by) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn crop_pages(&self, doc: &Open, pages: &[usize], margins: [f32; 4]) -> io::Result<()> {
|
||||
let (pages, n) = numbers(pages)?;
|
||||
checked(unsafe { ely_crop_pages(self.ctx, doc.0, pages.as_ptr(), n, margins.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn insert_blank(&self, doc: &Open, at: usize, (w, h): (f32, f32)) -> io::Result<()> {
|
||||
checked(unsafe { ely_insert_blank(self.ctx, doc.0, page(at)?, w, h) }).map(|_| ())
|
||||
}
|
||||
|
||||
/// Inserts the pages of `path`; how many came.
|
||||
pub fn insert_file(&self, doc: &Open, at: usize, path: &str) -> io::Result<usize> {
|
||||
let (path, mut count) = (text(path)?, 0);
|
||||
checked(unsafe { ely_insert_file(self.ctx, doc.0, page(at)?, path.as_ptr(), &mut count) })?;
|
||||
usize::try_from(count).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
/// Writes a compacted copy to `out`.
|
||||
pub fn save(&self, doc: &Open, out: &Path, password: &str) -> io::Result<()> {
|
||||
let scratch = raw(out);
|
||||
let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?);
|
||||
let saved = checked(unsafe { ely_save(self.ctx, doc.0, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr()) });
|
||||
clean(&scratch);
|
||||
saved.map(|_| ())
|
||||
}
|
||||
|
||||
/// Writes only `pages` to `out`; the document stays.
|
||||
pub fn extract(&self, doc: &Open, pages: &[usize], out: &Path, password: &str) -> io::Result<()> {
|
||||
let (pages, n) = numbers(pages)?;
|
||||
let scratch = raw(out);
|
||||
let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?);
|
||||
let done = checked(unsafe {
|
||||
ely_extract(self.ctx, doc.0, pages.as_ptr(), n, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr())
|
||||
});
|
||||
clean(&scratch);
|
||||
done.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a scratch copy; one never written is fine.
|
||||
pub(crate) fn clean(scratch: &Path) {
|
||||
match std::fs::remove_file(scratch) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => log::warn!("pdf-worker: the scratch copy stays: {error}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! The C shim's calls; each failure leaves a message behind.
|
||||
|
||||
use std::{
|
||||
ffi::{CStr, CString, c_char, c_int, c_void},
|
||||
io, ptr,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
pub struct Context {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
pub struct Document {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_error() -> *const c_char;
|
||||
fn ely_init() -> *mut Context;
|
||||
fn ely_open(ctx: *mut Context, path: *const c_char, out: *mut *mut Document) -> c_int;
|
||||
fn ely_close(ctx: *mut Context, doc: *mut Document);
|
||||
fn ely_needs_password(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_authenticate(ctx: *mut Context, doc: *mut Document, password: *const c_char) -> c_int;
|
||||
fn ely_count_pages(ctx: *mut Context, doc: *mut Document, out: *mut c_int) -> c_int;
|
||||
fn ely_page_bounds(ctx: *mut Context, doc: *mut Document, page: c_int, out: *mut f32) -> c_int;
|
||||
fn ely_render(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
page: c_int,
|
||||
scale: f32,
|
||||
rotate: c_int,
|
||||
tile: *const c_int,
|
||||
dark: c_int,
|
||||
bgra: *mut u8,
|
||||
) -> c_int;
|
||||
fn ely_text(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
page: c_int,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
fn ely_search(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
page: c_int,
|
||||
needle: *const c_char,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
fn ely_outline(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
fn ely_free(ctx: *mut Context, data: *mut c_void);
|
||||
fn ely_script_font(data: *const u8, len: usize);
|
||||
}
|
||||
|
||||
/// Great Vibes (OFL), for typed signatures.
|
||||
static SCRIPT: &[u8] = include_bytes!("../../../fonts/GreatVibes-Regular.ttf");
|
||||
|
||||
/// Hands MuPDF the script face, alive while the worker is.
|
||||
pub fn script_font() {
|
||||
unsafe { ely_script_font(SCRIPT.as_ptr(), SCRIPT.len()) }
|
||||
}
|
||||
|
||||
fn error() -> io::Error {
|
||||
let text = unsafe { CStr::from_ptr(ely_error()) };
|
||||
io::Error::other(text.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn checked(code: c_int) -> io::Result<c_int> {
|
||||
match code {
|
||||
-1 => Err(error()),
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text(value: &str) -> io::Result<CString> {
|
||||
CString::new(value).map_err(|_| io::Error::other("text with a NUL byte"))
|
||||
}
|
||||
|
||||
pub(crate) fn page(number: usize) -> io::Result<c_int> {
|
||||
c_int::try_from(number).map_err(|_| io::Error::other("a page number too large"))
|
||||
}
|
||||
|
||||
/// The one MuPDF context; the worker is single threaded.
|
||||
pub struct Mupdf {
|
||||
pub(crate) ctx: *mut Context,
|
||||
}
|
||||
|
||||
/// An open document, closed by `Mupdf::close`.
|
||||
pub struct Open(pub(crate) *mut Document);
|
||||
|
||||
impl Mupdf {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
let ctx = unsafe { ely_init() };
|
||||
match ctx.is_null() {
|
||||
true => Err(error()),
|
||||
false => Ok(Mupdf { ctx }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&self, path: &str) -> io::Result<Open> {
|
||||
let path = text(path)?;
|
||||
let mut doc = ptr::null_mut();
|
||||
checked(unsafe { ely_open(self.ctx, path.as_ptr(), &mut doc) })?;
|
||||
Ok(Open(doc))
|
||||
}
|
||||
|
||||
pub fn close(&self, doc: Open) {
|
||||
unsafe { ely_close(self.ctx, doc.0) }
|
||||
}
|
||||
|
||||
pub fn locked(&self, doc: &Open) -> io::Result<bool> {
|
||||
checked(unsafe { ely_needs_password(self.ctx, doc.0) }).map(|locked| locked != 0)
|
||||
}
|
||||
|
||||
/// MuPDF's access bits; 0 is refused, 4 the owner.
|
||||
pub fn unlock(&self, doc: &Open, password: &str) -> io::Result<u32> {
|
||||
let password = text(password)?;
|
||||
checked(unsafe { ely_authenticate(self.ctx, doc.0, password.as_ptr()) }).map(|access| access as u32)
|
||||
}
|
||||
|
||||
pub fn pages(&self, doc: &Open) -> io::Result<usize> {
|
||||
let mut count = 0;
|
||||
checked(unsafe { ely_count_pages(self.ctx, doc.0, &mut count) })?;
|
||||
usize::try_from(count).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
pub fn bounds(&self, doc: &Open, number: usize) -> io::Result<[f32; 4]> {
|
||||
let mut out = [0f32; 4];
|
||||
checked(unsafe { ely_page_bounds(self.ctx, doc.0, page(number)?, out.as_mut_ptr()) })?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
doc: &Open,
|
||||
number: usize,
|
||||
(scale, rotate): (f32, i32),
|
||||
tile: [i32; 4],
|
||||
dark: bool,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
let (w, h) = (usize::try_from(tile[2]), usize::try_from(tile[3]));
|
||||
let (Ok(w), Ok(h)) = (w, h) else {
|
||||
return Err(io::Error::other("a tile of negative size"));
|
||||
};
|
||||
let mut pixels = vec![0u8; w * h * 4];
|
||||
let code = unsafe {
|
||||
ely_render(
|
||||
self.ctx,
|
||||
doc.0,
|
||||
page(number)?,
|
||||
scale,
|
||||
rotate,
|
||||
tile.as_ptr(),
|
||||
c_int::from(dark),
|
||||
pixels.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
checked(code)?;
|
||||
Ok(pixels)
|
||||
}
|
||||
|
||||
/// Frees memory the shim handed over.
|
||||
pub(crate) fn free(&self, data: *mut u8) {
|
||||
unsafe { ely_free(self.ctx, data.cast()) }
|
||||
}
|
||||
|
||||
/// Takes a buffer the shim made, then frees it.
|
||||
pub(crate) fn owned(&self, call: impl FnOnce(*mut *mut u8, *mut usize) -> c_int) -> io::Result<Vec<u8>> {
|
||||
let (mut data, mut len) = (ptr::null_mut(), 0usize);
|
||||
checked(call(&mut data, &mut len))?;
|
||||
let bytes = match data.is_null() {
|
||||
true => Vec::new(),
|
||||
false => unsafe { std::slice::from_raw_parts(data, len) }.to_vec(),
|
||||
};
|
||||
unsafe { ely_free(self.ctx, data.cast()) };
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn text(&self, doc: &Open, number: usize) -> io::Result<Vec<u8>> {
|
||||
let number = page(number)?;
|
||||
self.owned(|data, len| unsafe { ely_text(self.ctx, doc.0, number, data, len) })
|
||||
}
|
||||
|
||||
pub fn outline(&self, doc: &Open) -> io::Result<Vec<u8>> {
|
||||
self.owned(|data, len| unsafe { ely_outline(self.ctx, doc.0, data, len) })
|
||||
}
|
||||
|
||||
/// Each hit as its quad count, then its quads.
|
||||
pub fn search(&self, doc: &Open, number: usize, needle: &str) -> io::Result<Vec<u8>> {
|
||||
let (needle, number) = (text(needle)?, page(number)?);
|
||||
self.owned(|data, len| unsafe { ely_search(self.ctx, doc.0, number, needle.as_ptr(), data, len) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! The PDF worker: MuPDF behind framed stdin and stdout.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{self, BufReader, BufWriter},
|
||||
};
|
||||
|
||||
use ely_ipc::{Pack, Unpack, read_frame, write_frame};
|
||||
use ely_pdf_engine::proto::{may, tag};
|
||||
|
||||
mod content;
|
||||
mod edit;
|
||||
mod ffi;
|
||||
mod marks;
|
||||
mod ocr;
|
||||
mod output;
|
||||
mod redact;
|
||||
mod secure;
|
||||
mod sign;
|
||||
mod structure;
|
||||
|
||||
use ffi::{Mupdf, Open};
|
||||
|
||||
/// An open document, its password and undo steps.
|
||||
struct Doc {
|
||||
open: Open,
|
||||
/// Kept in memory only, to reopen saved copies.
|
||||
password: String,
|
||||
/// Opened with the owner password, or unprotected.
|
||||
owner: bool,
|
||||
/// What MuPDF warned at open; kept as the repair report.
|
||||
warnings: String,
|
||||
repair: Option<String>,
|
||||
/// The repaired copy standing in; removed on close.
|
||||
copy: Option<std::path::PathBuf>,
|
||||
/// MuPDF's steps: an edit's id, or none for a save.
|
||||
steps: Vec<Option<u64>>,
|
||||
/// Steps done, as MuPDF counts them.
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
/// Equal marks, equal documents.
|
||||
fn mark(&self) -> String {
|
||||
self.steps[..self.at].iter().rev().flatten().next().map_or_else(|| "0".into(), u64::to_string)
|
||||
}
|
||||
|
||||
/// Matches MuPDF's steps; a new last step is `made`.
|
||||
fn sync(&mut self, mupdf: &Mupdf, made: Option<u64>) -> io::Result<()> {
|
||||
let (at, count) = mupdf.state(&self.open)?;
|
||||
if at > self.at {
|
||||
self.steps.truncate(self.at);
|
||||
self.steps.extend((self.at..at).map(|step| made.filter(|_| step + 1 == at)));
|
||||
}
|
||||
// Steps MuPDF keeps but no edit made are never redone.
|
||||
self.steps.resize(count, None);
|
||||
self.at = at;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undoes or redoes MuPDF steps until one edit is passed.
|
||||
fn step(&mut self, mupdf: &Mupdf, redo: bool) -> io::Result<String> {
|
||||
let able = match redo {
|
||||
true => self.steps[self.at..].iter().any(Option::is_some),
|
||||
false => self.steps[..self.at].iter().any(Option::is_some),
|
||||
};
|
||||
if !able {
|
||||
return Err(io::Error::other(format!("nothing to {}", if redo { "redo" } else { "undo" })));
|
||||
}
|
||||
loop {
|
||||
mupdf.undo(&self.open, redo)?;
|
||||
let passed = match redo {
|
||||
true => {
|
||||
self.at += 1;
|
||||
self.steps[self.at - 1]
|
||||
}
|
||||
false => {
|
||||
self.at -= 1;
|
||||
self.steps[self.at]
|
||||
}
|
||||
};
|
||||
if passed.is_some() {
|
||||
return Ok(self.mark());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
mupdf: Mupdf,
|
||||
docs: HashMap<u64, Doc>,
|
||||
next: u64,
|
||||
/// Edits made so far; each step's id.
|
||||
edits: u64,
|
||||
}
|
||||
|
||||
/// Sorted distinct page numbers.
|
||||
fn pages(ask: &mut Unpack) -> io::Result<Vec<usize>> {
|
||||
let count = ask.u64()? as usize;
|
||||
(0..count).map(|_| Ok(ask.u64()? as usize)).collect()
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn doc(&self, id: u64) -> io::Result<&Open> {
|
||||
Ok(&self.record(id)?.open)
|
||||
}
|
||||
|
||||
fn record(&self, id: u64) -> io::Result<&Doc> {
|
||||
self.docs
|
||||
.get(&id)
|
||||
.ok_or_else(|| io::Error::other(format!("no document {id}")))
|
||||
}
|
||||
|
||||
/// Runs one edit on `id`; it becomes the next step.
|
||||
fn edited(&mut self, id: u64, edit: impl FnOnce(&Mupdf, &Open) -> io::Result<()>) -> io::Result<String> {
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
if let Err(error) = edit(&self.mupdf, &doc.open) {
|
||||
// A failed edit may have dropped MuPDF's redo steps.
|
||||
doc.sync(&self.mupdf, None)?;
|
||||
return Err(error);
|
||||
}
|
||||
self.edits += 1;
|
||||
doc.sync(&self.mupdf, Some(self.edits))?;
|
||||
Ok(doc.mark())
|
||||
}
|
||||
|
||||
/// Writes or previews text; a missing glyph changes nothing.
|
||||
fn put_text(&mut self, id: u64, ask: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
let at = ask.u64()? as usize;
|
||||
let block = usize::try_from(ask.i64()?).ok();
|
||||
let (expected, text, font) = (ask.str()?, ask.str()?, ask.str()?);
|
||||
let size = f32::from_bits(ask.u64()? as u32);
|
||||
let rgb = ask.u64()? as u32;
|
||||
let mut bx = [0f32; 3];
|
||||
for value in &mut bx {
|
||||
*value = f32::from_bits(ask.u64()? as u32);
|
||||
}
|
||||
let scale = f32::from_bits(ask.u64()? as u32);
|
||||
let writing = content::Writing { block, expected, text, font, size, rgb, bx };
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
let wrote = self.mupdf.put_text(&doc.open, at, &writing, &doc.password, scale);
|
||||
if matches!(wrote, Ok(content::Wrote::Done)) {
|
||||
self.edits += 1;
|
||||
}
|
||||
let made = matches!(wrote, Ok(content::Wrote::Done)).then_some(self.edits);
|
||||
doc.sync(&self.mupdf, made)?;
|
||||
Ok(match wrote? {
|
||||
content::Wrote::Done => Pack::new().u64(0).str(&doc.mark()).bytes(),
|
||||
content::Wrote::Missing(c) => Pack::new().u64(2).u64(u64::from(c)).bytes(),
|
||||
content::Wrote::Preview(pixels, (w, h), grew) => {
|
||||
Pack::new().u64(1).u64(u64::from(w)).u64(u64::from(h)).u64(u64::from(grew)).blob(&pixels).bytes()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn edit(&mut self, kind: u8, ask: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
let id = ask.u64()?;
|
||||
self.guard(id, kind)?;
|
||||
let mark = match kind {
|
||||
tag::HISTORY => self.record(id)?.mark(),
|
||||
tag::UNDO => {
|
||||
let redo = ask.u64()? != 0;
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
doc.step(&self.mupdf, redo)?
|
||||
}
|
||||
tag::DELETE => {
|
||||
let pages = pages(ask)?;
|
||||
self.edited(id, |mupdf, doc| mupdf.delete_pages(doc, &pages))?
|
||||
}
|
||||
tag::MOVE => {
|
||||
let (pages, to) = (pages(ask)?, ask.u64()? as usize);
|
||||
self.edited(id, |mupdf, doc| mupdf.move_pages(doc, &pages, to))?
|
||||
}
|
||||
tag::ROTATE => {
|
||||
let (pages, by) = (pages(ask)?, i32::try_from(ask.i64()?).map_err(io::Error::other)?);
|
||||
self.edited(id, |mupdf, doc| mupdf.rotate_pages(doc, &pages, by))?
|
||||
}
|
||||
tag::CROP => {
|
||||
let pages = pages(ask)?;
|
||||
let mut margins = [0f32; 4];
|
||||
for side in &mut margins {
|
||||
*side = f32::from_bits(ask.u64()? as u32);
|
||||
}
|
||||
self.edited(id, |mupdf, doc| mupdf.crop_pages(doc, &pages, margins))?
|
||||
}
|
||||
tag::BLANK => {
|
||||
let at = ask.u64()? as usize;
|
||||
let size = (f32::from_bits(ask.u64()? as u32), f32::from_bits(ask.u64()? as u32));
|
||||
self.edited(id, |mupdf, doc| mupdf.insert_blank(doc, at, size))?
|
||||
}
|
||||
tag::INSERT => {
|
||||
let (at, path, mut count) = (ask.u64()? as usize, ask.str()?, 0);
|
||||
let mark = self.edited(id, |mupdf, doc| {
|
||||
count = mupdf.insert_file(doc, at, &path)?;
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(Pack::new().u64(count as u64).str(&mark).bytes());
|
||||
}
|
||||
tag::EXTRACT => {
|
||||
let (pages, out) = (pages(ask)?, ask.str()?);
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
let done = self.mupdf.extract(&doc.open, &pages, out.as_ref(), &doc.password);
|
||||
doc.sync(&self.mupdf, None)?;
|
||||
done?;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
tag::TEXT_BLOCKS => return self.mupdf.text_blocks(&self.record(id)?.open, ask.u64()? as usize),
|
||||
tag::OBJECTS => return self.mupdf.objects(&self.record(id)?.open, ask.u64()? as usize),
|
||||
tag::PUT_TEXT => return self.put_text(id, ask),
|
||||
tag::PLACE => {
|
||||
let at = ask.u64()? as usize;
|
||||
let (index, mut t) = (ask.u64()? as usize, [0f32; 6]);
|
||||
for value in &mut t {
|
||||
*value = f32::from_bits(ask.u64()? as u32);
|
||||
}
|
||||
let (drop, swap) = (ask.u64()? != 0, ask.str()?);
|
||||
let swap = Some(swap.as_str()).filter(|path| !path.is_empty());
|
||||
self.edited(id, |mupdf, doc| mupdf.place(doc, at, index, t, drop, swap))?
|
||||
}
|
||||
tag::ADD_IMAGE => {
|
||||
let (at, path, mut rect) = (ask.u64()? as usize, ask.str()?, [0f32; 4]);
|
||||
for value in &mut rect {
|
||||
*value = f32::from_bits(ask.u64()? as u32);
|
||||
}
|
||||
self.edited(id, |mupdf, doc| mupdf.add_image(doc, at, &path, rect))?
|
||||
}
|
||||
tag::ANNOTS => return self.mupdf.annots(&self.record(id)?.open, ask.u64()? as usize),
|
||||
tag::OCR_PAGE => {
|
||||
let (at, lang, datadir) = (ask.u64()? as usize, ask.str()?, ask.str()?);
|
||||
let dpi = f32::from_bits(ask.u64()? as u32);
|
||||
let lines = self.mupdf.ocr_page(&self.record(id)?.open, at, &lang, &datadir, dpi)?;
|
||||
log::info!("pdf-worker: OCR read page {at} of document {id}");
|
||||
return Ok(lines);
|
||||
}
|
||||
tag::TEXT_LAYER => {
|
||||
let (json, replace) = (ask.str()?, ask.u64()? != 0);
|
||||
self.edited(id, |mupdf, doc| mupdf.text_layer(doc, &json, replace))?
|
||||
}
|
||||
tag::OUTLINE_EDIT => {
|
||||
let json = ask.str()?;
|
||||
self.edited(id, |mupdf, doc| mupdf.outline_edit(doc, &json))?
|
||||
}
|
||||
tag::LINKS => return self.mupdf.links(&self.record(id)?.open, ask.u64()? as usize),
|
||||
tag::LINK_EDIT => {
|
||||
let (at, json) = (ask.u64()? as usize, ask.str()?);
|
||||
self.edited(id, |mupdf, doc| mupdf.link_edit(doc, at, &json))?
|
||||
}
|
||||
tag::METADATA => return self.mupdf.metadata(&self.record(id)?.open),
|
||||
tag::SET_METADATA => {
|
||||
let json = ask.str()?;
|
||||
self.edited(id, |mupdf, doc| mupdf.set_metadata(doc, &json))?
|
||||
}
|
||||
tag::ATTACHMENTS => return self.mupdf.attachments(&self.record(id)?.open),
|
||||
tag::ATTACHMENT => return self.mupdf.attachment(&self.record(id)?.open, &ask.str()?),
|
||||
tag::ATTACH => {
|
||||
let (name, path) = (ask.str()?, ask.str()?);
|
||||
self.edited(id, |mupdf, doc| mupdf.attach(doc, &name, &path))?
|
||||
}
|
||||
tag::DETACH => {
|
||||
let name = ask.str()?;
|
||||
self.edited(id, |mupdf, doc| mupdf.detach(doc, &name))?
|
||||
}
|
||||
tag::REDACT => {
|
||||
let (json, out) = (ask.str()?, ask.str()?);
|
||||
let record = self.record(id)?;
|
||||
let report = self.mupdf.redact(&record.open, &json, out.as_ref(), &record.password)?;
|
||||
log::info!("pdf-worker: wrote a redacted copy of document {id}");
|
||||
return Ok(report);
|
||||
}
|
||||
tag::CLEAN_SCAN => {
|
||||
let scratch = ask.str()?;
|
||||
let record = self.record(id)?;
|
||||
return self.mupdf.clean_scan(&record.open, scratch.as_ref(), &record.password);
|
||||
}
|
||||
tag::CLEAN => {
|
||||
let (mask, out) = (ask.u64()? as u32, ask.str()?);
|
||||
let record = self.record(id)?;
|
||||
let left = self.mupdf.clean(&record.open, mask, out.as_ref(), &record.password)?;
|
||||
log::info!("pdf-worker: wrote a cleaned copy of document {id}");
|
||||
return Ok(left);
|
||||
}
|
||||
tag::FIELD_NAMES => return self.mupdf.field_names(&self.record(id)?.open),
|
||||
tag::ANNOTATE => {
|
||||
let json = ask.str()?;
|
||||
self.edited(id, |mupdf, doc| mupdf.annotate(doc, &json))?
|
||||
}
|
||||
tag::CHANGE_ANNOT | tag::CHANGE_FIELD | tag::FILL | tag::DELETE_ANNOT => {
|
||||
let (at, object, value) = (ask.u64()? as usize, ask.u64()?, ask.str()?);
|
||||
if kind == tag::DELETE_ANNOT {
|
||||
let needed = if value == "field" { may::EDIT | may::ANNOTATE } else { may::ANNOTATE };
|
||||
secure::refuse(needed, self.allowed(id)?)?;
|
||||
}
|
||||
self.edited(id, |mupdf, doc| match kind {
|
||||
tag::CHANGE_ANNOT => mupdf.change_annot(doc, at, object, &value),
|
||||
tag::CHANGE_FIELD => mupdf.change_field(doc, at, object, &value),
|
||||
tag::FILL => mupdf.fill(doc, at, object, &value),
|
||||
_ => match value.as_str() {
|
||||
"field" | "annot" => mupdf.delete_annot(doc, at, object, value == "field"),
|
||||
other => Err(io::Error::other(format!("no object kind {other}"))),
|
||||
},
|
||||
})?
|
||||
}
|
||||
tag::ADD_FIELD => {
|
||||
let (at, json) = (ask.u64()? as usize, ask.str()?);
|
||||
self.edited(id, |mupdf, doc| mupdf.add_field(doc, at, &json))?
|
||||
}
|
||||
tag::SAVE => {
|
||||
let out = ask.str()?;
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
let done = self.mupdf.save(&doc.open, out.as_ref(), &doc.password);
|
||||
doc.sync(&self.mupdf, None)?;
|
||||
done?;
|
||||
log::info!("pdf-worker: saved document {id}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
tag::PERMISSIONS | tag::OWNER | tag::PROTECT => return self.secure(kind, id, ask),
|
||||
tag::SIGNATURES | tag::SIGN | tag::SIGNED => return self.signing(kind, id, ask),
|
||||
tag::CHECK_PICTURES => return self.check_pictures(id),
|
||||
tag::EXPORT_IMAGE | tag::EXPORT_TEXT | tag::OPTIMIZE | tag::PRINT_COPY | tag::REPAIR_REPORT => return self.output(kind, id, ask),
|
||||
other => return Err(io::Error::other(format!("no request {other}"))),
|
||||
};
|
||||
Ok(Pack::new().str(&mark).bytes())
|
||||
}
|
||||
|
||||
fn answer(&mut self, kind: u8, payload: &[u8]) -> io::Result<Vec<u8>> {
|
||||
// A document request names the state it was made for.
|
||||
let stamped;
|
||||
let payload = match kind {
|
||||
tag::OPEN | tag::HELLO => payload,
|
||||
_ => {
|
||||
stamped = self.current(payload)?;
|
||||
&stamped
|
||||
}
|
||||
};
|
||||
let mut ask = Unpack::new(payload);
|
||||
match kind {
|
||||
tag::OPEN => {
|
||||
let open = self.mupdf.open(&ask.str()?)?;
|
||||
let prepared = (|| {
|
||||
let locked = self.mupdf.locked(&open)?;
|
||||
// Unprotected files answer 1; owners carry bit 4.
|
||||
let owner = !locked && matches!(self.mupdf.unlock(&open, "")?, access if access == 1 || access & 4 != 0);
|
||||
self.mupdf.journal(&open)?;
|
||||
Ok::<_, io::Error>((locked, owner))
|
||||
})();
|
||||
// A failed start releases what MuPDF opened.
|
||||
let (locked, owner) = match prepared {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
self.mupdf.close(open);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
let warnings = output::open_warnings();
|
||||
let doc = Doc { open, password: String::new(), owner, warnings, repair: None, copy: None, steps: Vec::new(), at: 0 };
|
||||
self.docs.insert(id, doc);
|
||||
if !locked && let Err(error) = self.repair(id) {
|
||||
self.forget(id)?;
|
||||
return Err(error);
|
||||
}
|
||||
log::info!("pdf-worker: opened document {id}, locked {locked}");
|
||||
Ok(Pack::new().u64(id).u64(u64::from(locked)).bytes())
|
||||
}
|
||||
tag::UNLOCK => {
|
||||
let (id, password) = (ask.u64()?, ask.str()?);
|
||||
let access = self.mupdf.unlock(self.doc(id)?, &password)?;
|
||||
if access != 0 {
|
||||
let doc = self.docs.get_mut(&id).expect("just read");
|
||||
(doc.password, doc.owner) = (password, access & 4 != 0);
|
||||
self.repair(id)?;
|
||||
}
|
||||
Ok(Pack::new().u64(u64::from(access != 0)).bytes())
|
||||
}
|
||||
tag::PAGES => Ok(Pack::new()
|
||||
.u64(self.mupdf.pages(self.doc(ask.u64()?)?)? as u64)
|
||||
.bytes()),
|
||||
tag::BOUNDS => {
|
||||
let doc = self.doc(ask.u64()?)?;
|
||||
let (first, count) = (ask.u64()? as usize, ask.u64()? as usize);
|
||||
let mut out = Pack::new();
|
||||
for number in first..first + count {
|
||||
for value in self.mupdf.bounds(doc, number)? {
|
||||
out = out.u64(u64::from(value.to_bits()));
|
||||
}
|
||||
}
|
||||
Ok(out.bytes())
|
||||
}
|
||||
tag::RENDER => {
|
||||
let id = ask.u64()?;
|
||||
let page = ask.u64()? as usize;
|
||||
let scale = f32::from_bits(ask.u64()? as u32);
|
||||
let rotate = i32::try_from(ask.i64()?).map_err(io::Error::other)?;
|
||||
let mut tile = [0i32; 4];
|
||||
for value in &mut tile {
|
||||
*value = i32::try_from(ask.i64()?).map_err(io::Error::other)?;
|
||||
}
|
||||
let dark = ask.u64()? != 0;
|
||||
self.drawn(id, page, (scale, rotate), tile, dark)
|
||||
}
|
||||
tag::TEXT => {
|
||||
let doc = self.doc(ask.u64()?)?;
|
||||
self.mupdf.text(doc, ask.u64()? as usize)
|
||||
}
|
||||
tag::SEARCH => {
|
||||
let doc = self.doc(ask.u64()?)?;
|
||||
let page = ask.u64()? as usize;
|
||||
self.mupdf.search(doc, page, &ask.str()?)
|
||||
}
|
||||
tag::OUTLINE => self.mupdf.outline(self.doc(ask.u64()?)?),
|
||||
tag::HELLO => Ok(Pack::new().str(ely_pdf_engine::proto::BUILD).bytes()),
|
||||
tag::CLOSE => {
|
||||
self.forget(ask.u64()?)?;
|
||||
Ok(Vec::new())
|
||||
}
|
||||
other => self.edit(other, &mut ask),
|
||||
}
|
||||
}
|
||||
|
||||
/// The request without its revision, if it is still current.
|
||||
fn current(&self, payload: &[u8]) -> io::Result<Vec<u8>> {
|
||||
let mut ask = Unpack::new(payload);
|
||||
let (id, expected) = (ask.u64()?, ask.u64()?);
|
||||
let doc = self.record(id)?;
|
||||
if expected != ely_pdf_engine::proto::ANY && doc.mark() != expected.to_string() {
|
||||
return Err(io::Error::other(format!("{}document {id} is at {}, not {expected}", ely_pdf_engine::proto::STALE, doc.mark())));
|
||||
}
|
||||
Ok([&id.to_le_bytes()[..], ask.rest()].concat())
|
||||
}
|
||||
|
||||
/// Releases document `id` and its repaired copy.
|
||||
fn forget(&mut self, id: u64) -> io::Result<()> {
|
||||
let doc = self.docs.remove(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
self.mupdf.close(doc.open);
|
||||
if let Some(copy) = doc.copy {
|
||||
std::fs::remove_file(copy)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::new().filter_or("ELY_LOG", "info")).init();
|
||||
ffi::script_font();
|
||||
// Numbers from another worker process match nothing here.
|
||||
let started = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(io::Error::other)?;
|
||||
let prefix = (u64::from(std::process::id()) ^ u64::from(started.subsec_nanos())) & 0xffff_ffff;
|
||||
let mut worker = Worker {
|
||||
mupdf: Mupdf::new()?,
|
||||
docs: HashMap::new(),
|
||||
next: (prefix << 32) | 1,
|
||||
edits: 0,
|
||||
};
|
||||
let (mut input, mut output) = (
|
||||
BufReader::new(io::stdin().lock()),
|
||||
BufWriter::new(io::stdout().lock()),
|
||||
);
|
||||
while let Some((kind, id, payload)) = read_frame(&mut input)? {
|
||||
let written = match worker.answer(kind, &payload) {
|
||||
Ok(reply) => write_frame(&mut output, tag::OK, id, &reply),
|
||||
Err(error) => {
|
||||
// Text may quote the document: reply, never log.
|
||||
log::warn!("pdf-worker: request {kind} failed ({:?})", error.kind());
|
||||
write_frame(&mut output, tag::ERR, id, error.to_string().as_bytes())
|
||||
}
|
||||
};
|
||||
// An oversized answer is refused; the worker lives.
|
||||
if let Err(error) = written {
|
||||
if error.kind() != io::ErrorKind::InvalidInput {
|
||||
return Err(error);
|
||||
}
|
||||
log::warn!("pdf-worker: request {kind} answer too large");
|
||||
write_frame(&mut output, tag::ERR, id, error.to_string().as_bytes())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! The shim's annotation and form calls (PDF-05, PDF-06).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
};
|
||||
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_annots(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_annotate(ctx: *mut Context, doc: *mut Document, json: *const c_char) -> c_int;
|
||||
fn ely_change_annot(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, json: *const c_char) -> c_int;
|
||||
fn ely_delete_annot(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, widget: c_int) -> c_int;
|
||||
fn ely_add_field(ctx: *mut Context, doc: *mut Document, page: c_int, json: *const c_char) -> c_int;
|
||||
fn ely_fill(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, value: *const c_char) -> c_int;
|
||||
fn ely_field_names(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_change_field(ctx: *mut Context, doc: *mut Document, page: c_int, id: c_int, json: *const c_char) -> c_int;
|
||||
}
|
||||
|
||||
fn id(value: u64) -> io::Result<c_int> {
|
||||
c_int::try_from(value).map_err(|_| io::Error::other("an object number too large"))
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
pub fn annots(&self, doc: &Open, at: usize) -> io::Result<Vec<u8>> {
|
||||
let at = page(at)?;
|
||||
self.owned(|data, len| unsafe { ely_annots(self.ctx, doc.0, at, data, len) })
|
||||
}
|
||||
|
||||
pub fn field_names(&self, doc: &Open) -> io::Result<Vec<u8>> {
|
||||
self.owned(|data, len| unsafe { ely_field_names(self.ctx, doc.0, data, len) })
|
||||
}
|
||||
|
||||
pub fn annotate(&self, doc: &Open, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_annotate(self.ctx, doc.0, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn change_annot(&self, doc: &Open, at: usize, annot: u64, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_change_annot(self.ctx, doc.0, page(at)?, id(annot)?, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn delete_annot(&self, doc: &Open, at: usize, annot: u64, field: bool) -> io::Result<()> {
|
||||
checked(unsafe { ely_delete_annot(self.ctx, doc.0, page(at)?, id(annot)?, c_int::from(field)) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn add_field(&self, doc: &Open, at: usize, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_add_field(self.ctx, doc.0, page(at)?, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn fill(&self, doc: &Open, at: usize, field: u64, value: &str) -> io::Result<()> {
|
||||
let value = text(value)?;
|
||||
checked(unsafe { ely_fill(self.ctx, doc.0, page(at)?, id(field)?, value.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn change_field(&self, doc: &Open, at: usize, field: u64, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_change_field(self.ctx, doc.0, page(at)?, id(field)?, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! The shim's OCR calls (PDF-08).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
};
|
||||
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_ocr_page(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
page: c_int,
|
||||
lang: *const c_char,
|
||||
datadir: *const c_char,
|
||||
dpi: f32,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
fn ely_text_layer(ctx: *mut Context, doc: *mut Document, json: *const c_char, replace: c_int) -> c_int;
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
pub fn ocr_page(&self, doc: &Open, at: usize, lang: &str, datadir: &str, dpi: f32) -> io::Result<Vec<u8>> {
|
||||
let (at, lang, datadir) = (page(at)?, text(lang)?, text(datadir)?);
|
||||
self.owned(|data, len| unsafe { ely_ocr_page(self.ctx, doc.0, at, lang.as_ptr(), datadir.as_ptr(), dpi, data, len) })
|
||||
}
|
||||
|
||||
pub fn text_layer(&self, doc: &Open, json: &str, replace: bool) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_text_layer(self.ctx, doc.0, json.as_ptr(), c_int::from(replace)) }).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Page images, text, optimized and repaired copies (PDF-13, PDF-14).
|
||||
|
||||
use std::{
|
||||
ffi::{CStr, c_char, c_int},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_ipc::{Pack, Unpack};
|
||||
use ely_pdf_engine::proto::{may, tag};
|
||||
|
||||
use crate::edit::{clean, raw, utf8};
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
use crate::secure::refuse;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_export_image(ctx: *mut Context, doc: *mut Document, number: c_int, dpi: f32, path: *const c_char) -> c_int;
|
||||
fn ely_export_text(ctx: *mut Context, doc: *mut Document, path: *const c_char) -> c_int;
|
||||
fn ely_optimize(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
raw: *const c_char,
|
||||
out: *const c_char,
|
||||
password: *const c_char,
|
||||
compress: c_int,
|
||||
from: c_int,
|
||||
to: c_int,
|
||||
) -> c_int;
|
||||
fn ely_repaired(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_write_repaired(ctx: *mut Context, doc: *mut Document, path: *const c_char) -> c_int;
|
||||
fn ely_open_warnings() -> *const c_char;
|
||||
fn ely_scan(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_watch_begin(ctx: *mut Context);
|
||||
fn ely_watch_end(ctx: *mut Context) -> c_int;
|
||||
fn ely_check_pictures(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_print_copy(ctx: *mut Context, doc: *mut Document, raw: *const c_char, out: *const c_char, password: *const c_char) -> c_int;
|
||||
}
|
||||
|
||||
/// What MuPDF warned while opening the last file.
|
||||
pub fn open_warnings() -> String {
|
||||
unsafe { CStr::from_ptr(ely_open_warnings()) }.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
fn optimize(&self, doc: &Open, out: &Path, password: &str, compress: bool, (from, to): (u32, u32)) -> io::Result<()> {
|
||||
let scratch = raw(out);
|
||||
let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?);
|
||||
let (from, to) = (c_int::try_from(from).map_err(io::Error::other)?, c_int::try_from(to).map_err(io::Error::other)?);
|
||||
let done = checked(unsafe {
|
||||
ely_optimize(self.ctx, doc.0, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr(), c_int::from(compress), from, to)
|
||||
});
|
||||
clean(&scratch);
|
||||
if done.is_err() {
|
||||
clean(out);
|
||||
}
|
||||
done.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn repaired(&self, doc: &Open) -> io::Result<bool> {
|
||||
checked(unsafe { ely_repaired(self.ctx, doc.0) }).map(|repaired| repaired != 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Worker {
|
||||
/// MuPDF warned on reading: the file is damaged.
|
||||
fn note_damage(&mut self, id: u64) -> io::Result<()> {
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
let report = doc.repair.get_or_insert_with(String::new);
|
||||
report.push_str(&open_warnings());
|
||||
log::warn!("pdf-worker: document {id} is damaged");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A tile, and whether drawing it met damage.
|
||||
pub(crate) fn drawn(&mut self, id: u64, page: usize, view: (f32, i32), tile: [i32; 4], dark: bool) -> io::Result<Vec<u8>> {
|
||||
unsafe { ely_watch_begin(self.mupdf.ctx) };
|
||||
let pixels = self.mupdf.render(self.doc(id)?, page, view, tile, dark);
|
||||
let warned = unsafe { ely_watch_end(self.mupdf.ctx) } != 0;
|
||||
if warned {
|
||||
self.note_damage(id)?;
|
||||
}
|
||||
Ok(Pack::new().u64(u64::from(warned)).blob(&pixels?).bytes())
|
||||
}
|
||||
|
||||
/// Every picture decoded before an overwrite; true if any failed.
|
||||
pub(crate) fn check_pictures(&mut self, id: u64) -> io::Result<Vec<u8>> {
|
||||
let failed = checked(unsafe { ely_check_pictures(self.mupdf.ctx, self.doc(id)?.0) })? != 0;
|
||||
if failed {
|
||||
self.note_damage(id)?;
|
||||
}
|
||||
Ok(Pack::new().u64(u64::from(failed)).bytes())
|
||||
}
|
||||
|
||||
/// A repaired file gives way to a whole copy.
|
||||
pub(crate) fn repair(&mut self, id: u64) -> io::Result<()> {
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
if doc.repair.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Late repairs and broken streams show only on reading.
|
||||
let warned = checked(unsafe { ely_scan(self.mupdf.ctx, doc.open.0) })? != 0;
|
||||
doc.warnings.push_str(&open_warnings());
|
||||
if !warned && !self.mupdf.repaired(&doc.open)? {
|
||||
return Ok(());
|
||||
}
|
||||
let copy: PathBuf = std::env::temp_dir().join(format!("ely-pdf-{}-{id}.pdf", std::process::id()));
|
||||
let path = text(utf8(©)?)?;
|
||||
// Any failure here removes the copy and what was opened.
|
||||
let mupdf = &self.mupdf;
|
||||
let fresh = (|| {
|
||||
checked(unsafe { ely_write_repaired(mupdf.ctx, doc.open.0, path.as_ptr()) })?;
|
||||
let fresh = mupdf.open(utf8(©)?)?;
|
||||
let ready = (|| {
|
||||
if mupdf.locked(&fresh)? && mupdf.unlock(&fresh, &doc.password)? == 0 {
|
||||
return Err(io::Error::other("the repaired copy refused the password"));
|
||||
}
|
||||
mupdf.journal(&fresh)
|
||||
})();
|
||||
match ready {
|
||||
Ok(()) => Ok(fresh),
|
||||
Err(error) => {
|
||||
mupdf.close(fresh);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
})();
|
||||
let fresh = match fresh {
|
||||
Ok(fresh) => fresh,
|
||||
Err(error) => {
|
||||
clean(©);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let broken = std::mem::replace(&mut doc.open, fresh);
|
||||
self.mupdf.close(broken);
|
||||
doc.repair = Some(std::mem::take(&mut doc.warnings));
|
||||
doc.copy = Some(copy);
|
||||
log::info!("pdf-worker: document {id} was damaged; a repaired copy stands in");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn output(&mut self, kind: u8, id: u64, ask: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
let has = self.allowed(id)?;
|
||||
let doc = self.record(id)?;
|
||||
match kind {
|
||||
tag::EXPORT_IMAGE => {
|
||||
refuse(may::COPY, has)?;
|
||||
let (at, dpi, path) = (ask.u64()? as usize, f32::from_bits(ask.u64()? as u32), ask.str()?);
|
||||
let path_c = text(&path)?;
|
||||
checked(unsafe { ely_export_image(self.mupdf.ctx, doc.open.0, page(at)?, dpi, path_c.as_ptr()) })?;
|
||||
}
|
||||
tag::EXPORT_TEXT => {
|
||||
refuse(may::COPY, has)?;
|
||||
let path = text(&ask.str()?)?;
|
||||
checked(unsafe { ely_export_text(self.mupdf.ctx, doc.open.0, path.as_ptr()) })?;
|
||||
}
|
||||
tag::OPTIMIZE => {
|
||||
refuse(may::EDIT, has)?;
|
||||
let (out, compress) = (ask.str()?, ask.u64()? != 0);
|
||||
let (from, to) = (ask.u64()? as u32, ask.u64()? as u32);
|
||||
self.mupdf.optimize(&doc.open, out.as_ref(), &doc.password, compress, (from, to))?;
|
||||
}
|
||||
tag::PRINT_COPY => {
|
||||
refuse(may::PRINT, has)?;
|
||||
let out = ask.str()?;
|
||||
let scratch = raw(out.as_ref());
|
||||
let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(&out)?, text(&doc.password)?);
|
||||
let done = checked(unsafe { ely_print_copy(self.mupdf.ctx, doc.open.0, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr()) });
|
||||
clean(&scratch);
|
||||
done?;
|
||||
}
|
||||
tag::REPAIR_REPORT => {
|
||||
return Ok(match &doc.repair {
|
||||
Some(report) => Pack::new().u64(1).str(report).bytes(),
|
||||
None => Pack::new().u64(0).bytes(),
|
||||
});
|
||||
}
|
||||
other => return Err(io::Error::other(format!("no request {other}"))),
|
||||
}
|
||||
log::info!("pdf-worker: request {kind} done for document {id}");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! The shim's removal and cleaning calls (PDF-09, PDF-10).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use crate::edit::{clean, raw, utf8};
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_redact(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
json: *const c_char,
|
||||
raw: *const c_char,
|
||||
out: *const c_char,
|
||||
password: *const c_char,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
fn ely_clean_scan(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
raw: *const c_char,
|
||||
password: *const c_char,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
fn ely_clean(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
mask: c_int,
|
||||
raw: *const c_char,
|
||||
out: *const c_char,
|
||||
password: *const c_char,
|
||||
data: *mut *mut u8,
|
||||
len: *mut usize,
|
||||
) -> c_int;
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
/// Writes `out` with `json`'s regions removed; the check.
|
||||
pub fn redact(&self, doc: &Open, json: &str, out: &Path, password: &str) -> io::Result<Vec<u8>> {
|
||||
let scratch = raw(out);
|
||||
let (json, raw_c, out_c, password) = (text(json)?, text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?);
|
||||
let done = self.owned(|data, len| unsafe {
|
||||
ely_redact(self.ctx, doc.0, json.as_ptr(), raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr(), data, len)
|
||||
});
|
||||
clean(&scratch);
|
||||
// A file that failed its check is not left behind.
|
||||
if done.is_err() {
|
||||
clean(out);
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
/// Counts on a copy written to `scratch`, then removed.
|
||||
pub fn clean_scan(&self, doc: &Open, scratch: &Path, password: &str) -> io::Result<Vec<u8>> {
|
||||
let (raw_c, password) = (text(utf8(scratch)?)?, text(password)?);
|
||||
let done = self.owned(|data, len| unsafe { ely_clean_scan(self.ctx, doc.0, raw_c.as_ptr(), password.as_ptr(), data, len) });
|
||||
clean(scratch);
|
||||
done
|
||||
}
|
||||
|
||||
/// Writes `out` cleaned of `mask`; what is left.
|
||||
pub fn clean(&self, doc: &Open, mask: u32, out: &Path, password: &str) -> io::Result<Vec<u8>> {
|
||||
let mask = c_int::try_from(mask).map_err(io::Error::other)?;
|
||||
let scratch = raw(out);
|
||||
let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?);
|
||||
let done = self.owned(|data, len| unsafe {
|
||||
ely_clean(self.ctx, doc.0, mask, raw_c.as_ptr(), out_c.as_ptr(), password.as_ptr(), data, len)
|
||||
});
|
||||
clean(&scratch);
|
||||
if done.is_err() {
|
||||
clean(out);
|
||||
}
|
||||
done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Permissions, owner access and protected copies (PDF-11).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use ely_pdf_engine::proto::{may, tag};
|
||||
|
||||
use crate::edit::{clean, raw, utf8};
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_permissions(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_protect(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
raw: *const c_char,
|
||||
out: *const c_char,
|
||||
password: *const c_char,
|
||||
protect: c_int,
|
||||
user: *const c_char,
|
||||
owner: *const c_char,
|
||||
may: c_int,
|
||||
) -> c_int;
|
||||
}
|
||||
|
||||
/// What each request needs the document to allow.
|
||||
pub fn needs(kind: u8) -> u32 {
|
||||
match kind {
|
||||
tag::DELETE | tag::MOVE | tag::ROTATE | tag::BLANK | tag::INSERT | tag::OUTLINE_EDIT => may::ASSEMBLE,
|
||||
tag::CROP | tag::PUT_TEXT | tag::PLACE | tag::ADD_IMAGE | tag::TEXT_LAYER | tag::LINK_EDIT => may::EDIT,
|
||||
tag::SET_METADATA | tag::ATTACH | tag::DETACH | tag::REDACT | tag::CLEAN => may::EDIT,
|
||||
tag::ANNOTATE | tag::CHANGE_ANNOT => may::ANNOTATE,
|
||||
tag::ADD_FIELD | tag::CHANGE_FIELD => may::EDIT | may::ANNOTATE,
|
||||
tag::FILL => may::FILL,
|
||||
tag::EXTRACT => may::COPY,
|
||||
tag::PROTECT => may::OWNER,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bits `needed` lacks in `has`, as an error.
|
||||
pub fn refuse(needed: u32, has: u32) -> io::Result<()> {
|
||||
let missing = needed & !has;
|
||||
match may::NAMES.iter().find(|(bit, _)| missing & bit != 0) {
|
||||
Some((_, name)) => Err(io::Error::other(format!("{}{name}", may::REFUSED))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
/// Ely's permission bits; `owner` adds full access.
|
||||
pub fn permissions(&self, doc: &Open, owner: bool) -> io::Result<u32> {
|
||||
let bits = checked(unsafe { ely_permissions(self.ctx, doc.0) })? as u32;
|
||||
Ok(if owner { bits | may::OWNER } else { bits })
|
||||
}
|
||||
|
||||
/// Writes `out` protected, or unprotected without `lock`.
|
||||
pub fn protect(&self, doc: &Open, out: &Path, password: &str, lock: Option<(&str, &str, u32)>) -> io::Result<()> {
|
||||
let (user, owner, bits) = lock.unwrap_or(("", "", 0));
|
||||
let scratch = raw(out);
|
||||
let (raw_c, out_c, password) = (text(utf8(&scratch)?)?, text(utf8(out)?)?, text(password)?);
|
||||
let (user, owner) = (text(user)?, text(owner)?);
|
||||
let bits = c_int::try_from(bits).map_err(io::Error::other)?;
|
||||
let done = checked(unsafe {
|
||||
ely_protect(
|
||||
self.ctx,
|
||||
doc.0,
|
||||
raw_c.as_ptr(),
|
||||
out_c.as_ptr(),
|
||||
password.as_ptr(),
|
||||
c_int::from(lock.is_some()),
|
||||
user.as_ptr(),
|
||||
owner.as_ptr(),
|
||||
bits,
|
||||
)
|
||||
});
|
||||
clean(&scratch);
|
||||
if done.is_err() {
|
||||
clean(out);
|
||||
}
|
||||
done.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Worker {
|
||||
/// What document `id` allows now.
|
||||
pub(crate) fn allowed(&self, id: u64) -> io::Result<u32> {
|
||||
let doc = self.record(id)?;
|
||||
self.mupdf.permissions(&doc.open, doc.owner)
|
||||
}
|
||||
|
||||
/// Refuses `kind` on `id` when the document forbids it.
|
||||
pub(crate) fn guard(&self, id: u64, kind: u8) -> io::Result<()> {
|
||||
match needs(kind) {
|
||||
0 => Ok(()),
|
||||
needed => refuse(needed, self.allowed(id)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn secure(&mut self, kind: u8, id: u64, ask: &mut ely_ipc::Unpack) -> io::Result<Vec<u8>> {
|
||||
match kind {
|
||||
tag::PERMISSIONS => Ok(ely_ipc::Pack::new().u64(u64::from(self.allowed(id)?)).bytes()),
|
||||
tag::OWNER => {
|
||||
let password = ask.str()?;
|
||||
let doc = self.docs.get_mut(&id).ok_or_else(|| io::Error::other(format!("no document {id}")))?;
|
||||
let access = self.mupdf.unlock(&doc.open, &password)?;
|
||||
let owner = access & 4 != 0;
|
||||
match owner {
|
||||
true => (doc.password, doc.owner) = (password, true),
|
||||
// A refused try drops MuPDF's keys; the old ones return.
|
||||
false => _ = self.mupdf.unlock(&doc.open, &doc.password)?,
|
||||
}
|
||||
log::info!("pdf-worker: owner password for document {id}: {}", if owner { "accepted" } else { "refused" });
|
||||
Ok(ely_ipc::Pack::new().u64(u64::from(owner)).bytes())
|
||||
}
|
||||
tag::PROTECT => {
|
||||
let (out, lock) = (ask.str()?, ask.u64()? != 0);
|
||||
let (user, owner, bits) = (ask.str()?, ask.str()?, ask.u64()? as u32);
|
||||
let doc = self.record(id)?;
|
||||
let lock = lock.then_some((user.as_str(), owner.as_str(), bits));
|
||||
self.mupdf.protect(&doc.open, out.as_ref(), &doc.password, lock)?;
|
||||
log::info!("pdf-worker: wrote a {} copy of document {id}", if lock.is_some() { "protected" } else { "unprotected" });
|
||||
Ok(Vec::new())
|
||||
}
|
||||
other => Err(io::Error::other(format!("no request {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! The shim's signature calls (PDF-12).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use ely_pdf_engine::{Signing, proto::{may, tag}};
|
||||
|
||||
use crate::edit::{clean, utf8};
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
use crate::secure::refuse;
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_signatures(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_signed(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_sign(
|
||||
ctx: *mut Context,
|
||||
doc: *mut Document,
|
||||
raw: *const c_char,
|
||||
password: *const c_char,
|
||||
pfx: *const c_char,
|
||||
pfx_password: *const c_char,
|
||||
number: c_int,
|
||||
object: c_int,
|
||||
rect: *const f32,
|
||||
name: *const c_char,
|
||||
reason: *const c_char,
|
||||
location: *const c_char,
|
||||
) -> c_int;
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
pub fn signatures(&self, doc: &Open) -> io::Result<Vec<u8>> {
|
||||
self.owned(|data, len| unsafe { ely_signatures(self.ctx, doc.0, data, len) })
|
||||
}
|
||||
|
||||
/// Writes `out` signed; a failure leaves nothing.
|
||||
pub fn sign(&self, doc: &Open, out: &Path, password: &str, how: &Signing) -> io::Result<()> {
|
||||
let object = match how.object {
|
||||
Some(object) => c_int::try_from(object).map_err(io::Error::other)?,
|
||||
None => -1,
|
||||
};
|
||||
let (out_c, password) = (text(utf8(out)?)?, text(password)?);
|
||||
let (pfx, pfx_password, name) = (text(&how.pfx)?, text(&how.pfx_password)?, text(&how.name)?);
|
||||
let (reason, location) = (text(&how.reason)?, text(&how.location)?);
|
||||
let done = checked(unsafe {
|
||||
ely_sign(
|
||||
self.ctx,
|
||||
doc.0,
|
||||
out_c.as_ptr(),
|
||||
password.as_ptr(),
|
||||
pfx.as_ptr(),
|
||||
pfx_password.as_ptr(),
|
||||
page(how.page)?,
|
||||
object,
|
||||
how.rect.as_ptr(),
|
||||
name.as_ptr(),
|
||||
reason.as_ptr(),
|
||||
location.as_ptr(),
|
||||
)
|
||||
});
|
||||
if done.is_err() {
|
||||
clean(out);
|
||||
}
|
||||
done.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Worker {
|
||||
pub(crate) fn signing(&mut self, kind: u8, id: u64, ask: &mut ely_ipc::Unpack) -> io::Result<Vec<u8>> {
|
||||
match kind {
|
||||
tag::SIGNATURES => {
|
||||
// A file's signatures, not the edited copy's.
|
||||
let path = ask.str()?;
|
||||
let password = self.record(id)?.password.clone();
|
||||
let file = self.mupdf.open(&path)?;
|
||||
let read = match self.mupdf.locked(&file) {
|
||||
Ok(true) if self.mupdf.unlock(&file, &password)? == 0 => Err(io::Error::other("the file refused the document's password")),
|
||||
Ok(_) => self.mupdf.signatures(&file),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
self.mupdf.close(file);
|
||||
read
|
||||
}
|
||||
tag::SIGNED => {
|
||||
let count = checked(unsafe { ely_signed(self.mupdf.ctx, self.record(id)?.open.0) })?;
|
||||
Ok(ely_ipc::Pack::new().u64(count as u64).bytes())
|
||||
}
|
||||
tag::SIGN => {
|
||||
let out = ask.str()?;
|
||||
let (page, object) = (ask.u64()? as usize, ask.i64()?);
|
||||
let mut rect = [0f32; 4];
|
||||
for value in &mut rect {
|
||||
*value = f32::from_bits(ask.u64()? as u32);
|
||||
}
|
||||
let (name, pfx, pfx_password) = (ask.str()?, ask.str()?, ask.str()?);
|
||||
let (reason, location) = (ask.str()?, ask.str()?);
|
||||
let object = u32::try_from(object).ok();
|
||||
// Filling a field, or adding one to the page.
|
||||
let needed = if object.is_some() { may::FILL } else { may::EDIT | may::ANNOTATE };
|
||||
refuse(needed, self.allowed(id)?)?;
|
||||
let how = Signing { page, object, rect, name, pfx, pfx_password, reason, location };
|
||||
let doc = self.record(id)?;
|
||||
self.mupdf.sign(&doc.open, out.as_ref(), &doc.password, &how)?;
|
||||
log::info!("pdf-worker: wrote a signed copy of document {id}");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
other => Err(io::Error::other(format!("no request {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The shim's bookmark, link, property and attachment calls (PDF-07).
|
||||
|
||||
use std::{
|
||||
ffi::{c_char, c_int},
|
||||
io,
|
||||
};
|
||||
|
||||
use crate::ffi::{Context, Document, Mupdf, Open, checked, page, text};
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ely_outline_edit(ctx: *mut Context, doc: *mut Document, json: *const c_char) -> c_int;
|
||||
fn ely_links(ctx: *mut Context, doc: *mut Document, page: c_int, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_link_edit(ctx: *mut Context, doc: *mut Document, page: c_int, json: *const c_char) -> c_int;
|
||||
fn ely_metadata(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_set_metadata(ctx: *mut Context, doc: *mut Document, json: *const c_char) -> c_int;
|
||||
fn ely_attachments(ctx: *mut Context, doc: *mut Document, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_attachment(ctx: *mut Context, doc: *mut Document, name: *const c_char, data: *mut *mut u8, len: *mut usize) -> c_int;
|
||||
fn ely_attach(ctx: *mut Context, doc: *mut Document, name: *const c_char, path: *const c_char) -> c_int;
|
||||
fn ely_detach(ctx: *mut Context, doc: *mut Document, name: *const c_char) -> c_int;
|
||||
}
|
||||
|
||||
impl Mupdf {
|
||||
pub fn outline_edit(&self, doc: &Open, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_outline_edit(self.ctx, doc.0, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn links(&self, doc: &Open, at: usize) -> io::Result<Vec<u8>> {
|
||||
let at = page(at)?;
|
||||
self.owned(|data, len| unsafe { ely_links(self.ctx, doc.0, at, data, len) })
|
||||
}
|
||||
|
||||
pub fn link_edit(&self, doc: &Open, at: usize, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_link_edit(self.ctx, doc.0, page(at)?, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn metadata(&self, doc: &Open) -> io::Result<Vec<u8>> {
|
||||
self.owned(|data, len| unsafe { ely_metadata(self.ctx, doc.0, data, len) })
|
||||
}
|
||||
|
||||
pub fn set_metadata(&self, doc: &Open, json: &str) -> io::Result<()> {
|
||||
let json = text(json)?;
|
||||
checked(unsafe { ely_set_metadata(self.ctx, doc.0, json.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn attachments(&self, doc: &Open) -> io::Result<Vec<u8>> {
|
||||
self.owned(|data, len| unsafe { ely_attachments(self.ctx, doc.0, data, len) })
|
||||
}
|
||||
|
||||
pub fn attachment(&self, doc: &Open, name: &str) -> io::Result<Vec<u8>> {
|
||||
let name = text(name)?;
|
||||
self.owned(|data, len| unsafe { ely_attachment(self.ctx, doc.0, name.as_ptr(), data, len) })
|
||||
}
|
||||
|
||||
pub fn attach(&self, doc: &Open, name: &str, path: &str) -> io::Result<()> {
|
||||
let (name, path) = (text(name)?, text(path)?);
|
||||
checked(unsafe { ely_attach(self.ctx, doc.0, name.as_ptr(), path.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn detach(&self, doc: &Open, name: &str) -> io::Result<()> {
|
||||
let name = text(name)?;
|
||||
checked(unsafe { ely_detach(self.ctx, doc.0, name.as_ptr()) }).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! The app's side: spawn the worker, ask, read the answer.
|
||||
|
||||
use std::{
|
||||
io::{self, BufReader, BufWriter},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
|
||||
};
|
||||
|
||||
use ely_ipc::{Pack, Unpack, check_reply, read_frame, write_frame};
|
||||
|
||||
use crate::proto::{self, Bookmark, Char, tag};
|
||||
|
||||
|
||||
/// The PDF worker process.
|
||||
pub struct Worker {
|
||||
child: Child,
|
||||
stdin: BufWriter<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
/// The last request's id; each reply must carry it.
|
||||
asked: u64,
|
||||
/// This worker among all spawned; its documents carry it.
|
||||
session: u64,
|
||||
}
|
||||
|
||||
static SESSIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
/// An open document: its worker, number and history mark.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Doc {
|
||||
worker: u64,
|
||||
id: u64,
|
||||
/// The history mark this handle was read at.
|
||||
mark: u64,
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
/// This document as of history `mark`, which an edit answered.
|
||||
pub fn at(self, mark: &str) -> io::Result<Doc> {
|
||||
let mark = mark.parse().map_err(|_| io::Error::other(format!("a history mark that is not a number: {mark}")))?;
|
||||
Ok(Doc { mark, ..self })
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `error` refused a request made for an older state.
|
||||
pub fn is_stale(error: &io::Error) -> bool {
|
||||
error.to_string().starts_with(proto::STALE)
|
||||
}
|
||||
|
||||
/// Drawn pixels, BGRA, and whether drawing them met damage.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Tile {
|
||||
pub bgra: Vec<u8>,
|
||||
pub damaged: bool,
|
||||
}
|
||||
|
||||
/// A document just opened: locked until its password is given.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Opened {
|
||||
pub doc: Doc,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
const WORKER: &str = if cfg!(windows) {
|
||||
"ely-pdf-worker.exe"
|
||||
} else {
|
||||
"ely-pdf-worker"
|
||||
};
|
||||
|
||||
/// The worker binary: `ELY_PDF_WORKER`, beside the app, or this build's.
|
||||
fn worker_bin() -> io::Result<PathBuf> {
|
||||
if let Some(bin) = std::env::var_os("ELY_PDF_WORKER") {
|
||||
let bin = PathBuf::from(bin);
|
||||
return match bin.is_file() {
|
||||
true => Ok(bin),
|
||||
false => Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("ELY_PDF_WORKER {} is not there", bin.display()),
|
||||
)),
|
||||
};
|
||||
}
|
||||
let exe = std::env::current_exe()?;
|
||||
let beside = exe.parent().unwrap_or(Path::new(".")).join(WORKER);
|
||||
if beside.is_file() {
|
||||
return Ok(beside);
|
||||
}
|
||||
// Test binaries live one folder below the worker.
|
||||
let above = exe
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.map(|dir| dir.join(WORKER));
|
||||
above.filter(|bin| bin.is_file()).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"no ely-pdf-worker binary (set ELY_PDF_WORKER)",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn spawn() -> io::Result<Self> {
|
||||
Self::spawn_from(&worker_bin()?)
|
||||
}
|
||||
|
||||
/// Spawns `bin`; one from other sources is refused.
|
||||
pub fn spawn_from(bin: &Path) -> io::Result<Self> {
|
||||
let mut child = Command::new(bin)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| io::Error::new(error.kind(), format!("pdf-worker spawn: {error}")))?;
|
||||
// Its records reach the app log; nothing reads them back.
|
||||
drop(ely_ipc::relay(child.stderr.take().expect("piped stderr"), "pdf-worker"));
|
||||
let stdin = BufWriter::new(child.stdin.take().expect("piped stdin"));
|
||||
let stdout = BufReader::new(child.stdout.take().expect("piped stdout"));
|
||||
let mut worker = Worker {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
asked: 0,
|
||||
session: SESSIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
};
|
||||
log::info!("pdf: worker from {}", bin.display());
|
||||
let reply = worker.request(tag::HELLO, &[])?;
|
||||
if Unpack::new(&reply).str()? != proto::BUILD {
|
||||
return Err(io::Error::other(format!(
|
||||
"the pdf worker {} is from other sources; run cargo build -p ely-pdf-engine --bins",
|
||||
bin.display()
|
||||
)));
|
||||
}
|
||||
log::info!("pdf: worker up");
|
||||
Ok(worker)
|
||||
}
|
||||
|
||||
/// `doc` as it is now, for tests that chain edits.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn now(&mut self, doc: Doc) -> io::Result<Doc> {
|
||||
let mark = self.history(doc)?;
|
||||
doc.at(&mark)
|
||||
}
|
||||
|
||||
/// Why the worker process ended, if it has.
|
||||
pub fn ended(&mut self) -> Option<String> {
|
||||
match self.child.try_wait() {
|
||||
Ok(status) => status.map(|status| status.to_string()),
|
||||
Err(error) => Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker's process id, for resource tests.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn pid(&self) -> u32 {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
/// A request aimed at pages or objects `doc` saw.
|
||||
pub(crate) fn on(&self, doc: &Doc) -> io::Result<Pack> {
|
||||
Ok(Pack::new().u64(self.doc_id(doc)?).u64(doc.mark))
|
||||
}
|
||||
|
||||
/// A request on `doc` whatever its state: the whole document.
|
||||
pub(crate) fn whole(&self, doc: &Doc) -> io::Result<Pack> {
|
||||
Ok(Pack::new().u64(self.doc_id(doc)?).u64(proto::ANY))
|
||||
}
|
||||
|
||||
/// `doc`'s number here; another worker's document is refused.
|
||||
fn doc_id(&self, doc: &Doc) -> io::Result<u64> {
|
||||
match doc.worker == self.session {
|
||||
true => Ok(doc.id),
|
||||
false => Err(io::Error::new(io::ErrorKind::NotFound, "a document of another pdf worker")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request(&mut self, kind: u8, payload: &[u8]) -> io::Result<Vec<u8>> {
|
||||
self.asked += 1;
|
||||
write_frame(&mut self.stdin, kind, self.asked, payload)?;
|
||||
let frame = read_frame(&mut self.stdout)?;
|
||||
if let Some((_, id, _)) = &frame {
|
||||
check_reply(self.asked, *id)?;
|
||||
}
|
||||
match frame {
|
||||
Some((tag::OK, _, reply)) => Ok(reply),
|
||||
Some((tag::ERR, _, reply)) => Err(refusal(String::from_utf8_lossy(&reply).into_owned())),
|
||||
Some((other, _, _)) => Err(io::Error::other(format!("pdf-worker answered tag {other}"))),
|
||||
None => Err(io::Error::other(match self.child.try_wait() {
|
||||
Ok(Some(status)) => format!("the pdf worker exited: {status}"),
|
||||
_ => "the pdf worker closed its pipe".to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `path`; a locked file waits for `unlock`.
|
||||
pub fn open(&mut self, path: &Path) -> io::Result<Opened> {
|
||||
let path = path
|
||||
.to_str()
|
||||
.ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
let reply = self.request(tag::OPEN, &Pack::new().str(path).bytes())?;
|
||||
let mut reply = Unpack::new(&reply);
|
||||
Ok(Opened {
|
||||
doc: Doc { worker: self.session, id: reply.u64()?, mark: 0 },
|
||||
locked: reply.u64()? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tries `password`; false when it is wrong.
|
||||
pub fn unlock(&mut self, doc: Doc, password: &str) -> io::Result<bool> {
|
||||
let reply = self.request(tag::UNLOCK, &self.whole(&doc)?.str(password).bytes())?;
|
||||
Ok(Unpack::new(&reply).u64()? != 0)
|
||||
}
|
||||
|
||||
pub fn pages(&mut self, doc: Doc) -> io::Result<usize> {
|
||||
let reply = self.request(tag::PAGES, &self.whole(&doc)?.bytes())?;
|
||||
Ok(Unpack::new(&reply).u64()? as usize)
|
||||
}
|
||||
|
||||
/// Page boxes in points, `count` from `first`.
|
||||
pub fn bounds(&mut self, doc: Doc, first: usize, count: usize) -> io::Result<Vec<[f32; 4]>> {
|
||||
let ask = self.on(&doc)?
|
||||
.u64(first as u64)
|
||||
.u64(count as u64)
|
||||
.bytes();
|
||||
let reply = self.request(tag::BOUNDS, &ask)?;
|
||||
let mut reply = Unpack::new(&reply);
|
||||
let mut boxes = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let mut one = [0f32; 4];
|
||||
for value in &mut one {
|
||||
*value = f32::from_bits(reply.u64()? as u32);
|
||||
}
|
||||
boxes.push(one);
|
||||
}
|
||||
match reply.rest().is_empty() {
|
||||
true => Ok(boxes),
|
||||
false => Err(io::Error::other("a bounds reply too long")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's device pixels `tile` (x, y, w, h), BGRA.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
doc: Doc,
|
||||
page: usize,
|
||||
(scale, rotate): (f32, i32),
|
||||
tile: [i32; 4],
|
||||
dark: bool,
|
||||
) -> io::Result<Tile> {
|
||||
let mut ask = self.on(&doc)?
|
||||
.u64(page as u64)
|
||||
.u64(u64::from(scale.to_bits()))
|
||||
.i64(i64::from(rotate));
|
||||
for value in tile {
|
||||
ask = ask.i64(i64::from(value));
|
||||
}
|
||||
let reply = self.request(tag::RENDER, &ask.u64(u64::from(dark)).bytes())?;
|
||||
let mut reply = Unpack::new(&reply);
|
||||
let (damaged, bgra) = (reply.u64()? != 0, reply.blob()?);
|
||||
match bgra.len() == tile[2] as usize * tile[3] as usize * 4 {
|
||||
true => Ok(Tile { bgra, damaged }),
|
||||
false => Err(io::Error::other("a tile of the wrong size")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&mut self, doc: Doc, page: usize) -> io::Result<Vec<Char>> {
|
||||
proto::chars(&self.request(tag::TEXT, &self.on(&doc)?.u64(page as u64).bytes())?)
|
||||
}
|
||||
|
||||
/// Where `needle` is on a page: each hit's quads.
|
||||
pub fn search(&mut self, doc: Doc, page: usize, needle: &str) -> io::Result<Vec<Vec<[f32; 8]>>> {
|
||||
let ask = self.on(&doc)?.u64(page as u64).str(needle).bytes();
|
||||
proto::hits(&self.request(tag::SEARCH, &ask)?)
|
||||
}
|
||||
|
||||
pub fn outline(&mut self, doc: Doc) -> io::Result<Vec<Bookmark>> {
|
||||
let reply = self.request(tag::OUTLINE, &self.whole(&doc)?.bytes())?;
|
||||
proto::outline(&String::from_utf8(reply).map_err(io::Error::other)?)
|
||||
}
|
||||
|
||||
pub fn close(&mut self, doc: Doc) -> io::Result<()> {
|
||||
self.request(tag::CLOSE, &self.whole(&doc)?.bytes())
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
// The worker holds nothing that needs saving.
|
||||
if let Err(error) = self.child.kill() {
|
||||
log::info!("pdf: the worker was already gone: {error}");
|
||||
}
|
||||
if let Err(error) = self.child.wait() {
|
||||
log::warn!("pdf: the worker was not reaped: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A permission refusal is `PermissionDenied`, named by what it needs.
|
||||
fn refusal(text: String) -> io::Error {
|
||||
match text.strip_prefix(crate::proto::may::REFUSED) {
|
||||
Some(needed) => io::Error::new(io::ErrorKind::PermissionDenied, needed.to_string()),
|
||||
None => io::Error::other(text),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Text blocks and drawn objects: read, write, move (PDF-03, PDF-04).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_ipc::{Pack, Unpack};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// A text block as MuPDF reads it, in page points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextBlock {
|
||||
pub rect: [f32; 4],
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub size: f32,
|
||||
pub rgb: u32,
|
||||
/// Upright, and every glyph in the page's own content.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
/// An image or form XObject drawn on the page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Drawn {
|
||||
pub form: bool,
|
||||
pub rect: [f32; 4],
|
||||
}
|
||||
|
||||
/// Text to write, replacing block `block` or new.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Writing {
|
||||
pub block: Option<usize>,
|
||||
/// The block's text as read; a change since refuses.
|
||||
pub expected: String,
|
||||
pub text: String,
|
||||
/// "original", "helvetica", "times", "courier", "cjk-sc" and so on.
|
||||
pub font: String,
|
||||
pub size: f32,
|
||||
pub rgb: u32,
|
||||
/// Page points: left, top, width.
|
||||
pub bx: [f32; 3],
|
||||
}
|
||||
|
||||
/// What writing did, or would do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Wrote {
|
||||
/// Done; the history mark after it.
|
||||
Done(String),
|
||||
/// The font lacks this character; nothing changed.
|
||||
Missing(char),
|
||||
/// BGRA of the area, its size, whether text grew.
|
||||
Preview(Vec<u8>, (u32, u32), bool),
|
||||
}
|
||||
|
||||
fn rect(value: &Value) -> io::Result<[f32; 4]> {
|
||||
let bad = || io::Error::other(format!("a bad rectangle {value}"));
|
||||
let list = value.as_array().filter(|list| list.len() == 4).ok_or_else(bad)?;
|
||||
let mut out = [0f32; 4];
|
||||
for (at, side) in list.iter().enumerate() {
|
||||
out[at] = side.as_f64().ok_or_else(bad)? as f32;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list(bytes: &[u8]) -> io::Result<Vec<Value>> {
|
||||
match serde_json::from_slice(bytes).map_err(io::Error::other)? {
|
||||
Value::Array(list) => Ok(list),
|
||||
other => Err(io::Error::other(format!("not a list: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn block(value: &Value) -> io::Result<TextBlock> {
|
||||
let bad = || io::Error::other(format!("a bad text block {value}"));
|
||||
Ok(TextBlock {
|
||||
rect: rect(&value["rect"])?,
|
||||
text: value["text"].as_str().ok_or_else(bad)?.to_string(),
|
||||
font: value["font"].as_str().ok_or_else(bad)?.to_string(),
|
||||
size: value["size"].as_f64().ok_or_else(bad)? as f32,
|
||||
rgb: value["color"].as_u64().ok_or_else(bad)? as u32,
|
||||
editable: value["editable"].as_bool().ok_or_else(bad)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn floats(ask: Pack, values: &[f32]) -> Pack {
|
||||
values.iter().fold(ask, |ask, value| ask.u64(u64::from(value.to_bits())))
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn text_blocks(&mut self, doc: Doc, page: usize) -> io::Result<Vec<TextBlock>> {
|
||||
let reply = self.request(tag::TEXT_BLOCKS, &self.on(&doc)?.u64(page as u64).bytes())?;
|
||||
list(&reply)?.iter().map(block).collect()
|
||||
}
|
||||
|
||||
pub fn objects(&mut self, doc: Doc, page: usize) -> io::Result<Vec<Drawn>> {
|
||||
let reply = self.request(tag::OBJECTS, &self.on(&doc)?.u64(page as u64).bytes())?;
|
||||
list(&reply)?
|
||||
.iter()
|
||||
.map(|value| Ok(Drawn { form: value["kind"] == "form", rect: rect(&value["rect"])? }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Writes `w` on `page`; a `scale` above zero only previews.
|
||||
pub fn put_text(&mut self, doc: Doc, page: usize, w: &Writing, scale: f32) -> io::Result<Wrote> {
|
||||
let block = w.block.map_or(-1, |block| block as i64);
|
||||
let ask = self.on(&doc)?.u64(page as u64).i64(block).str(&w.expected).str(&w.text).str(&w.font);
|
||||
let ask = floats(ask.u64(u64::from(w.size.to_bits())).u64(u64::from(w.rgb)), &w.bx);
|
||||
let reply = self.request(tag::PUT_TEXT, &ask.u64(u64::from(scale.to_bits())).bytes())?;
|
||||
let mut reply = Unpack::new(&reply);
|
||||
match reply.u64()? {
|
||||
0 => Ok(Wrote::Done(reply.str()?)),
|
||||
2 => {
|
||||
let code = u32::try_from(reply.u64()?).map_err(io::Error::other)?;
|
||||
Ok(Wrote::Missing(char::from_u32(code).ok_or_else(|| io::Error::other("a bad missing glyph"))?))
|
||||
}
|
||||
_ => {
|
||||
let size = (reply.u64()? as u32, reply.u64()? as u32);
|
||||
let grew = reply.u64()? != 0;
|
||||
Ok(Wrote::Preview(reply.blob()?, size, grew))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves or scales draw `index` by page-space `t`; the mark.
|
||||
pub fn place(&mut self, doc: Doc, page: usize, index: usize, t: [f32; 6]) -> io::Result<String> {
|
||||
self.placing(doc, page, index, t, false, "")
|
||||
}
|
||||
|
||||
pub fn delete_object(&mut self, doc: Doc, page: usize, index: usize) -> io::Result<String> {
|
||||
self.placing(doc, page, index, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], true, "")
|
||||
}
|
||||
|
||||
/// Swaps image `index` for `path`, fitted in its box.
|
||||
pub fn replace_image(&mut self, doc: Doc, page: usize, index: usize, path: &Path) -> io::Result<String> {
|
||||
let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
self.placing(doc, page, index, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], false, path)
|
||||
}
|
||||
|
||||
fn placing(&mut self, doc: Doc, page: usize, index: usize, t: [f32; 6], drop: bool, swap: &str) -> io::Result<String> {
|
||||
let ask = floats(self.on(&doc)?.u64(page as u64).u64(index as u64), &t);
|
||||
let reply = self.request(tag::PLACE, &ask.u64(u64::from(drop)).str(swap).bytes())?;
|
||||
Unpack::new(&reply).str()
|
||||
}
|
||||
|
||||
/// Draws the image at `path` into page-space `rect`; the mark.
|
||||
pub fn add_image(&mut self, doc: Doc, page: usize, path: &Path, rect: [f32; 4]) -> io::Result<String> {
|
||||
let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
let ask = floats(self.on(&doc)?.u64(page as u64).str(path), &rect);
|
||||
Unpack::new(&self.request(tag::ADD_IMAGE, &ask.bytes())?).str()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Page edits, undo and saving (PDF-02).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_ipc::{Pack, Unpack};
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// `ask` (the document) then sorted, distinct `pages`.
|
||||
fn with_pages(ask: Pack, pages: &[usize]) -> Pack {
|
||||
pages
|
||||
.iter()
|
||||
.fold(ask.u64(pages.len() as u64), |ask, page| ask.u64(*page as u64))
|
||||
}
|
||||
|
||||
fn utf8(path: &Path) -> io::Result<&str> {
|
||||
path.to_str()
|
||||
.ok_or_else(|| io::Error::other("a path that is not UTF-8"))
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// The history mark: equal marks, equal documents.
|
||||
pub fn history(&mut self, doc: Doc) -> io::Result<String> {
|
||||
self.mark(tag::HISTORY, self.whole(&doc)?)
|
||||
}
|
||||
|
||||
fn mark(&mut self, kind: u8, ask: Pack) -> io::Result<String> {
|
||||
Unpack::new(&self.request(kind, &ask.bytes())?).str()
|
||||
}
|
||||
|
||||
/// Undoes, or redoes, one edit.
|
||||
pub fn undo(&mut self, doc: Doc, redo: bool) -> io::Result<String> {
|
||||
self.mark(tag::UNDO, self.whole(&doc)?.u64(u64::from(redo)))
|
||||
}
|
||||
|
||||
pub fn delete_pages(&mut self, doc: Doc, pages: &[usize]) -> io::Result<String> {
|
||||
self.mark(tag::DELETE, with_pages(self.on(&doc)?, pages))
|
||||
}
|
||||
|
||||
/// Moves `pages` before page `to`, keeping their order.
|
||||
pub fn move_pages(&mut self, doc: Doc, pages: &[usize], to: usize) -> io::Result<String> {
|
||||
self.mark(tag::MOVE, with_pages(self.on(&doc)?, pages).u64(to as u64))
|
||||
}
|
||||
|
||||
/// Turns `pages` clockwise by `by` degrees, a multiple of 90.
|
||||
pub fn rotate_pages(&mut self, doc: Doc, pages: &[usize], by: i32) -> io::Result<String> {
|
||||
self.mark(tag::ROTATE, with_pages(self.on(&doc)?, pages).i64(i64::from(by)))
|
||||
}
|
||||
|
||||
/// Crops by margins as seen: top, right, bottom, left points.
|
||||
pub fn crop_pages(&mut self, doc: Doc, pages: &[usize], margins: [f32; 4]) -> io::Result<String> {
|
||||
let ask = margins
|
||||
.iter()
|
||||
.fold(with_pages(self.on(&doc)?, pages), |ask, side| ask.u64(u64::from(side.to_bits())));
|
||||
self.mark(tag::CROP, ask)
|
||||
}
|
||||
|
||||
/// A blank page of `size` points at `at`.
|
||||
pub fn insert_blank(&mut self, doc: Doc, at: usize, (w, h): (f32, f32)) -> io::Result<String> {
|
||||
let ask = self.on(&doc)?.u64(at as u64);
|
||||
self.mark(tag::BLANK, ask.u64(u64::from(w.to_bits())).u64(u64::from(h.to_bits())))
|
||||
}
|
||||
|
||||
/// Inserts every page of `path`; answers count and mark.
|
||||
pub fn insert_file(&mut self, doc: Doc, at: usize, path: &Path) -> io::Result<(usize, String)> {
|
||||
let ask = self.on(&doc)?.u64(at as u64).str(utf8(path)?).bytes();
|
||||
let reply = self.request(tag::INSERT, &ask)?;
|
||||
let mut reply = Unpack::new(&reply);
|
||||
Ok((reply.u64()? as usize, reply.str()?))
|
||||
}
|
||||
|
||||
/// Writes only `pages` to `path`; the document stays.
|
||||
pub fn extract(&mut self, doc: Doc, pages: &[usize], path: &Path) -> io::Result<()> {
|
||||
let ask = with_pages(self.on(&doc)?, pages).str(utf8(path)?).bytes();
|
||||
self.request(tag::EXTRACT, &ask).map(|_| ())
|
||||
}
|
||||
|
||||
/// Writes the document, compacted, to `path`.
|
||||
pub fn save(&mut self, doc: Doc, path: &Path) -> io::Result<()> {
|
||||
let ask = self.whole(&doc)?.str(utf8(path)?).bytes();
|
||||
self.request(tag::SAVE, &ask).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! MuPDF in its own process: protocol and client (PDF-01).
|
||||
|
||||
mod client;
|
||||
mod content;
|
||||
mod edit;
|
||||
mod marks;
|
||||
mod ocr;
|
||||
mod output;
|
||||
mod redact;
|
||||
mod secure;
|
||||
mod sign;
|
||||
mod structure;
|
||||
pub mod proto;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod sample;
|
||||
|
||||
pub use client::{Doc, Opened, Tile, Worker, is_stale};
|
||||
pub use content::{Drawn, TextBlock, Writing, Wrote};
|
||||
pub use ocr::{OcrLine, tessdata};
|
||||
pub use output::Optimizing;
|
||||
pub use redact::{Cleaning, Reach, Redacted, Region};
|
||||
pub use secure::Lock;
|
||||
pub use sign::{Digest, Signature, Signed, Signing};
|
||||
pub use structure::{Attachment, BookmarkChange, Link, LinkChange, Properties, Target};
|
||||
pub use marks::{Annot, Field, FieldKind, Marks, NewAnnot, NewField, Shape};
|
||||
pub use proto::{Bookmark, Char};
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Annotations and form fields: read, add, change (PDF-05, PDF-06).
|
||||
|
||||
use std::io;
|
||||
|
||||
use ely_ipc::Unpack;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// An annotation as the page holds it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Annot {
|
||||
/// The object number; stays through edits.
|
||||
pub id: u64,
|
||||
/// The PDF subtype: Highlight, Text, Ink and so on.
|
||||
pub kind: String,
|
||||
pub rect: [f32; 4],
|
||||
pub rgb: Option<u32>,
|
||||
pub contents: String,
|
||||
pub author: String,
|
||||
pub icon: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FieldKind {
|
||||
Text,
|
||||
Checkbox,
|
||||
Radio,
|
||||
Choice,
|
||||
List,
|
||||
Signature,
|
||||
Button,
|
||||
}
|
||||
|
||||
/// One widget of a form field.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Field {
|
||||
pub id: u64,
|
||||
pub kind: FieldKind,
|
||||
pub name: String,
|
||||
pub rect: [f32; 4],
|
||||
pub value: String,
|
||||
/// A button's on state.
|
||||
pub on: String,
|
||||
pub checked: bool,
|
||||
pub readonly: bool,
|
||||
/// Choice values, and what each shows.
|
||||
pub options: Vec<String>,
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a page holds besides content.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Marks {
|
||||
pub annots: Vec<Annot>,
|
||||
pub fields: Vec<Field>,
|
||||
}
|
||||
|
||||
/// A new annotation's shape, in page points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shape {
|
||||
Highlight(Vec<[f32; 4]>),
|
||||
Underline(Vec<[f32; 4]>),
|
||||
StrikeOut(Vec<[f32; 4]>),
|
||||
Note((f32, f32)),
|
||||
Square([f32; 4]),
|
||||
Circle([f32; 4]),
|
||||
Line([f32; 4]),
|
||||
Ink(Vec<Vec<(f32, f32)>>),
|
||||
Stamp([f32; 4], String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NewAnnot {
|
||||
pub page: usize,
|
||||
pub shape: Shape,
|
||||
pub rgb: Option<u32>,
|
||||
pub contents: String,
|
||||
pub author: String,
|
||||
}
|
||||
|
||||
/// A new field; radios of one name form a group.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NewField {
|
||||
pub kind: FieldKind,
|
||||
pub name: String,
|
||||
pub rect: [f32; 4],
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
/// The last part of the name, which a rename sets.
|
||||
pub fn leaf(&self) -> &str {
|
||||
self.name.rfind('.').map_or(self.name.as_str(), |dot| &self.name[dot + 1..])
|
||||
}
|
||||
}
|
||||
|
||||
impl FieldKind {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
FieldKind::Text => "text",
|
||||
FieldKind::Checkbox => "checkbox",
|
||||
FieldKind::Radio => "radio",
|
||||
FieldKind::Choice => "choice",
|
||||
FieldKind::List => "list",
|
||||
FieldKind::Signature => "signature",
|
||||
FieldKind::Button => "button",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(annot: &NewAnnot) -> Value {
|
||||
let (kind, geometry) = match &annot.shape {
|
||||
Shape::Highlight(rects) => ("highlight", json!({ "rects": rects })),
|
||||
Shape::Underline(rects) => ("underline", json!({ "rects": rects })),
|
||||
Shape::StrikeOut(rects) => ("strikeout", json!({ "rects": rects })),
|
||||
Shape::Note(at) => ("note", json!({ "at": [at.0, at.1] })),
|
||||
Shape::Square(rect) => ("square", json!({ "rect": rect })),
|
||||
Shape::Circle(rect) => ("circle", json!({ "rect": rect })),
|
||||
Shape::Line(line) => ("line", json!({ "line": line })),
|
||||
Shape::Ink(strokes) => {
|
||||
let flat: Vec<Vec<f32>> = strokes.iter().map(|s| s.iter().flat_map(|p| [p.0, p.1]).collect()).collect();
|
||||
("ink", json!({ "strokes": flat }))
|
||||
}
|
||||
Shape::Stamp(rect, icon) => ("stamp", json!({ "rect": rect, "icon": icon })),
|
||||
};
|
||||
let mut spec = json!({ "page": annot.page, "kind": kind, "contents": annot.contents, "author": annot.author });
|
||||
if let Some(rgb) = annot.rgb {
|
||||
spec["color"] = json!(rgb);
|
||||
}
|
||||
spec.as_object_mut().expect("an object").extend(geometry.as_object().expect("an object").clone());
|
||||
spec
|
||||
}
|
||||
|
||||
fn bad(what: &str, value: &Value) -> io::Error {
|
||||
io::Error::other(format!("a bad {what} {value}"))
|
||||
}
|
||||
|
||||
fn rect(value: &Value) -> io::Result<[f32; 4]> {
|
||||
let list = value.as_array().filter(|list| list.len() == 4).ok_or_else(|| bad("rectangle", value))?;
|
||||
let mut out = [0f32; 4];
|
||||
for (at, side) in list.iter().enumerate() {
|
||||
out[at] = side.as_f64().ok_or_else(|| bad("rectangle", value))? as f32;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text(value: &Value, key: &str) -> io::Result<String> {
|
||||
value[key].as_str().map(str::to_string).ok_or_else(|| bad(key, value))
|
||||
}
|
||||
|
||||
fn texts(value: &Value, key: &str) -> io::Result<Vec<String>> {
|
||||
let list = value[key].as_array().ok_or_else(|| bad(key, value))?;
|
||||
list.iter().map(|item| item.as_str().map(str::to_string).ok_or_else(|| bad(key, value))).collect()
|
||||
}
|
||||
|
||||
fn annot(value: &Value) -> io::Result<Annot> {
|
||||
let color = value["color"].as_i64().ok_or_else(|| bad("annotation", value))?;
|
||||
Ok(Annot {
|
||||
id: value["id"].as_u64().ok_or_else(|| bad("annotation", value))?,
|
||||
kind: text(value, "kind")?,
|
||||
rect: rect(&value["rect"])?,
|
||||
rgb: u32::try_from(color).ok(),
|
||||
contents: text(value, "contents")?,
|
||||
author: text(value, "author")?,
|
||||
icon: text(value, "icon")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn field(value: &Value) -> io::Result<Field> {
|
||||
let kind = match value["kind"].as_str() {
|
||||
Some("text") => FieldKind::Text,
|
||||
Some("checkbox") => FieldKind::Checkbox,
|
||||
Some("radio") => FieldKind::Radio,
|
||||
Some("choice") => FieldKind::Choice,
|
||||
Some("list") => FieldKind::List,
|
||||
Some("signature") => FieldKind::Signature,
|
||||
Some("button") => FieldKind::Button,
|
||||
_ => return Err(bad("field", value)),
|
||||
};
|
||||
let flag = |key: &str| value[key].as_bool().ok_or_else(|| bad(key, value));
|
||||
Ok(Field {
|
||||
id: value["id"].as_u64().ok_or_else(|| bad("field", value))?,
|
||||
kind,
|
||||
name: text(value, "name")?,
|
||||
rect: rect(&value["rect"])?,
|
||||
value: text(value, "value")?,
|
||||
on: text(value, "on")?,
|
||||
checked: flag("checked")?,
|
||||
readonly: flag("readonly")?,
|
||||
options: texts(value, "options")?,
|
||||
labels: texts(value, "labels")?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn marks(&mut self, doc: Doc, page: usize) -> io::Result<Marks> {
|
||||
let reply = self.request(tag::ANNOTS, &self.on(&doc)?.u64(page as u64).bytes())?;
|
||||
let value: Value = serde_json::from_slice(&reply).map_err(io::Error::other)?;
|
||||
let list = |key: &str| value[key].as_array().ok_or_else(|| bad(key, &value));
|
||||
Ok(Marks {
|
||||
annots: list("annots")?.iter().map(annot).collect::<io::Result<_>>()?,
|
||||
fields: list("fields")?.iter().map(field).collect::<io::Result<_>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every field name in the form, widgets or not.
|
||||
pub fn field_names(&mut self, doc: Doc) -> io::Result<Vec<String>> {
|
||||
let reply = self.request(tag::FIELD_NAMES, &self.whole(&doc)?.bytes())?;
|
||||
let value: Value = serde_json::from_slice(&reply).map_err(io::Error::other)?;
|
||||
let list = value.as_array().ok_or_else(|| bad("name list", &value))?;
|
||||
list.iter().map(|name| name.as_str().map(str::to_string).ok_or_else(|| bad("field name", name))).collect()
|
||||
}
|
||||
|
||||
/// Adds `annots` as one step; the mark.
|
||||
pub fn annotate(&mut self, doc: Doc, annots: &[NewAnnot]) -> io::Result<String> {
|
||||
let specs = Value::Array(annots.iter().map(spec).collect());
|
||||
Unpack::new(&self.request(tag::ANNOTATE, &self.on(&doc)?.str(&specs.to_string()).bytes())?).str()
|
||||
}
|
||||
|
||||
fn on_object(&mut self, kind: u8, doc: Doc, page: usize, id: u64, value: &str) -> io::Result<String> {
|
||||
let ask = self.on(&doc)?.u64(page as u64).u64(id).str(value);
|
||||
Unpack::new(&self.request(kind, &ask.bytes())?).str()
|
||||
}
|
||||
|
||||
pub fn change_annot(&mut self, doc: Doc, page: usize, id: u64, rgb: Option<u32>, contents: Option<&str>) -> io::Result<String> {
|
||||
let mut change = json!({});
|
||||
if let Some(rgb) = rgb {
|
||||
change["color"] = json!(rgb);
|
||||
}
|
||||
if let Some(contents) = contents {
|
||||
change["contents"] = json!(contents);
|
||||
}
|
||||
self.on_object(tag::CHANGE_ANNOT, doc, page, id, &change.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_annot(&mut self, doc: Doc, page: usize, id: u64) -> io::Result<String> {
|
||||
self.on_object(tag::DELETE_ANNOT, doc, page, id, "annot")
|
||||
}
|
||||
|
||||
pub fn delete_field(&mut self, doc: Doc, page: usize, id: u64) -> io::Result<String> {
|
||||
self.on_object(tag::DELETE_ANNOT, doc, page, id, "field")
|
||||
}
|
||||
|
||||
/// Sets a value; buttons take their on state or Off.
|
||||
pub fn fill(&mut self, doc: Doc, page: usize, id: u64, value: &str) -> io::Result<String> {
|
||||
self.on_object(tag::FILL, doc, page, id, value)
|
||||
}
|
||||
|
||||
pub fn change_field(&mut self, doc: Doc, page: usize, id: u64, name: Option<&str>, options: Option<&[String]>) -> io::Result<String> {
|
||||
let mut change = json!({});
|
||||
if let Some(name) = name {
|
||||
change["name"] = json!(name);
|
||||
}
|
||||
if let Some(options) = options {
|
||||
change["options"] = json!(options);
|
||||
}
|
||||
self.on_object(tag::CHANGE_FIELD, doc, page, id, &change.to_string())
|
||||
}
|
||||
|
||||
pub fn add_field(&mut self, doc: Doc, page: usize, field: &NewField) -> io::Result<String> {
|
||||
let spec = json!({ "kind": field.kind.name(), "name": field.name, "rect": field.rect, "options": field.options });
|
||||
let ask = self.on(&doc)?.u64(page as u64).str(&spec.to_string());
|
||||
Unpack::new(&self.request(tag::ADD_FIELD, &ask.bytes())?).str()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! OCR: read pages, then lay their text in hidden (PDF-08).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_ipc::Unpack;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// A line Tesseract read, in page points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OcrLine {
|
||||
pub rect: [f32; 4],
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Beside the app, in its macOS bundle, or this build's.
|
||||
fn beside(dir: &Path) -> PathBuf {
|
||||
[dir.join("tessdata"), dir.join("../Resources/tessdata")]
|
||||
.into_iter()
|
||||
.find(|found| found.is_dir())
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/tessdata"))
|
||||
}
|
||||
|
||||
/// The language data: `ELY_TESSDATA`, the app's, or this build's.
|
||||
pub fn tessdata() -> io::Result<PathBuf> {
|
||||
let found = match std::env::var_os("ELY_TESSDATA") {
|
||||
Some(dir) => PathBuf::from(dir),
|
||||
None => {
|
||||
let exe = std::env::current_exe()?;
|
||||
beside(exe.parent().unwrap_or(Path::new(".")))
|
||||
}
|
||||
};
|
||||
match found.join("eng.traineddata").is_file() {
|
||||
true => Ok(found),
|
||||
false => Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no OCR data in {} (run scripts/fetch-tessdata.sh or set ELY_TESSDATA)", found.display()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn line(value: &Value) -> io::Result<OcrLine> {
|
||||
let bad = || io::Error::other(format!("a bad OCR line {value}"));
|
||||
let list = value["rect"].as_array().filter(|list| list.len() == 4).ok_or_else(bad)?;
|
||||
let mut rect = [0f32; 4];
|
||||
for (at, side) in list.iter().enumerate() {
|
||||
rect[at] = side.as_f64().ok_or_else(bad)? as f32;
|
||||
}
|
||||
Ok(OcrLine { rect, text: value["text"].as_str().ok_or_else(bad)?.to_string() })
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// Reads `page` in `langs` (as "eng+chi_sim") at `dpi`.
|
||||
pub fn ocr_page(&mut self, doc: Doc, page: usize, langs: &str, data: &Path, dpi: f32) -> io::Result<Vec<OcrLine>> {
|
||||
let data = data.to_str().ok_or_else(|| io::Error::other("a data path that is not UTF-8"))?;
|
||||
let ask = self.on(&doc)?.u64(page as u64).str(langs).str(data).u64(u64::from(dpi.to_bits()));
|
||||
let reply: Value = serde_json::from_slice(&self.request(tag::OCR_PAGE, &ask.bytes())?).map_err(io::Error::other)?;
|
||||
reply.as_array().ok_or_else(|| io::Error::other("an OCR reply that is not a list"))?.iter().map(line).collect()
|
||||
}
|
||||
|
||||
/// Lays `pages` in hidden text, one step; the mark.
|
||||
pub fn text_layer(&mut self, doc: Doc, pages: &[(usize, Vec<OcrLine>)], replace: bool) -> io::Result<String> {
|
||||
let spec: Vec<Value> = (pages.iter())
|
||||
.map(|(page, lines)| json!({ "page": page, "lines": lines.iter().map(|l| json!({ "rect": l.rect, "text": l.text })).collect::<Vec<_>>() }))
|
||||
.collect();
|
||||
let ask = self.on(&doc)?.str(&Value::Array(spec).to_string()).u64(u64::from(replace));
|
||||
Unpack::new(&self.request(tag::TEXT_LAYER, &ask.bytes())?).str()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::beside;
|
||||
|
||||
#[test]
|
||||
fn a_bundle_finds_its_own_data() {
|
||||
let app = tempfile::tempdir().expect("a temp dir");
|
||||
let (exe, data) = (app.path().join("Contents/MacOS"), app.path().join("Contents/Resources/tessdata"));
|
||||
std::fs::create_dir_all(&exe).expect("MacOS");
|
||||
assert!(beside(&exe).ends_with("engines/tessdata"));
|
||||
std::fs::create_dir_all(&data).expect("tessdata");
|
||||
assert_eq!(beside(&exe), exe.join("../Resources/tessdata"));
|
||||
std::fs::create_dir(exe.join("tessdata")).expect("beside");
|
||||
assert_eq!(beside(&exe), exe.join("tessdata"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Page images, text, optimized copies, repair reports (PDF-13, PDF-14).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_ipc::Unpack;
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// How an optimized copy is written.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Optimizing {
|
||||
pub compress: bool,
|
||||
/// Images above `from` dpi go to `to`; 0 keeps them.
|
||||
pub from: u32,
|
||||
pub to: u32,
|
||||
}
|
||||
|
||||
fn utf8(path: &Path) -> io::Result<&str> {
|
||||
path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// Page `page` as a PNG at `dpi`, white as printed.
|
||||
pub fn export_image(&mut self, doc: Doc, page: usize, dpi: f32, path: &Path) -> io::Result<()> {
|
||||
let ask = self.on(&doc)?.u64(page as u64).u64(u64::from(dpi.to_bits())).str(utf8(path)?);
|
||||
self.request(tag::EXPORT_IMAGE, &ask.bytes()).map(|_| ())
|
||||
}
|
||||
|
||||
/// Every page's text, UTF-8; form feeds part the pages.
|
||||
pub fn export_text(&mut self, doc: Doc, path: &Path) -> io::Result<()> {
|
||||
self.request(tag::EXPORT_TEXT, &self.whole(&doc)?.str(utf8(path)?).bytes()).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn optimize(&mut self, doc: Doc, out: &Path, how: Optimizing) -> io::Result<()> {
|
||||
let ask = self.whole(&doc)?.str(utf8(out)?).u64(u64::from(how.compress)).u64(u64::from(how.from)).u64(u64::from(how.to));
|
||||
self.request(tag::OPTIMIZE, &ask.bytes()).map(|_| ())
|
||||
}
|
||||
|
||||
/// The document as it is now, written to print.
|
||||
pub fn print_copy(&mut self, doc: Doc, out: &Path) -> io::Result<()> {
|
||||
self.request(tag::PRINT_COPY, &self.whole(&doc)?.str(utf8(out)?).bytes()).map(|_| ())
|
||||
}
|
||||
|
||||
/// Decodes every picture; true if any failed.
|
||||
pub fn check_pictures(&mut self, doc: Doc) -> io::Result<bool> {
|
||||
let reply = self.request(tag::CHECK_PICTURES, &self.whole(&doc)?.bytes())?;
|
||||
Ok(Unpack::new(&reply).u64()? != 0)
|
||||
}
|
||||
|
||||
/// MuPDF's warnings if the file was repaired to open.
|
||||
pub fn repair_report(&mut self, doc: Doc) -> io::Result<Option<String>> {
|
||||
let reply = self.request(tag::REPAIR_REPORT, &self.whole(&doc)?.bytes())?;
|
||||
let mut reply = Unpack::new(&reply);
|
||||
Ok(match reply.u64()? {
|
||||
0 => None,
|
||||
_ => Some(reply.str()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! What the app asks the PDF worker, and the answers.
|
||||
|
||||
use std::io;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Request tags; replies are OK or ERR.
|
||||
pub mod tag {
|
||||
pub const OPEN: u8 = 1;
|
||||
pub const UNLOCK: u8 = 2;
|
||||
pub const PAGES: u8 = 3;
|
||||
pub const BOUNDS: u8 = 4;
|
||||
pub const RENDER: u8 = 5;
|
||||
pub const TEXT: u8 = 6;
|
||||
pub const SEARCH: u8 = 7;
|
||||
pub const OUTLINE: u8 = 8;
|
||||
pub const CLOSE: u8 = 9;
|
||||
/// The worker's build stamp, checked on spawn.
|
||||
pub const HELLO: u8 = 10;
|
||||
/// Edits answer with the history mark after them.
|
||||
pub const HISTORY: u8 = 11;
|
||||
pub const UNDO: u8 = 12;
|
||||
pub const DELETE: u8 = 13;
|
||||
pub const MOVE: u8 = 14;
|
||||
pub const ROTATE: u8 = 15;
|
||||
pub const CROP: u8 = 16;
|
||||
pub const BLANK: u8 = 17;
|
||||
pub const INSERT: u8 = 18;
|
||||
pub const EXTRACT: u8 = 19;
|
||||
pub const SAVE: u8 = 20;
|
||||
pub const TEXT_BLOCKS: u8 = 21;
|
||||
pub const OBJECTS: u8 = 22;
|
||||
/// Writes text; with a scale, only previews it.
|
||||
pub const PUT_TEXT: u8 = 23;
|
||||
pub const PLACE: u8 = 24;
|
||||
pub const ADD_IMAGE: u8 = 25;
|
||||
/// A page's annotations and fields, as JSON.
|
||||
pub const ANNOTS: u8 = 26;
|
||||
pub const ANNOTATE: u8 = 27;
|
||||
pub const CHANGE_ANNOT: u8 = 28;
|
||||
pub const DELETE_ANNOT: u8 = 29;
|
||||
pub const ADD_FIELD: u8 = 30;
|
||||
pub const FILL: u8 = 31;
|
||||
pub const CHANGE_FIELD: u8 = 32;
|
||||
/// Every field name in the form.
|
||||
pub const FIELD_NAMES: u8 = 33;
|
||||
/// Reads one page's lines; changes nothing.
|
||||
pub const OCR_PAGE: u8 = 34;
|
||||
pub const TEXT_LAYER: u8 = 35;
|
||||
pub const OUTLINE_EDIT: u8 = 36;
|
||||
pub const LINKS: u8 = 37;
|
||||
pub const LINK_EDIT: u8 = 38;
|
||||
pub const METADATA: u8 = 39;
|
||||
pub const SET_METADATA: u8 = 40;
|
||||
pub const ATTACHMENTS: u8 = 41;
|
||||
/// An attachment's bytes.
|
||||
pub const ATTACHMENT: u8 = 42;
|
||||
pub const ATTACH: u8 = 43;
|
||||
pub const DETACH: u8 = 44;
|
||||
/// Writes a new file with regions removed, then checks it.
|
||||
pub const REDACT: u8 = 45;
|
||||
pub const CLEAN_SCAN: u8 = 46;
|
||||
pub const CLEAN: u8 = 47;
|
||||
pub const PERMISSIONS: u8 = 48;
|
||||
/// Tries an owner password; a wrong one changes nothing.
|
||||
pub const OWNER: u8 = 49;
|
||||
pub const PROTECT: u8 = 50;
|
||||
/// A file's signature fields and their checks, as JSON.
|
||||
pub const SIGNATURES: u8 = 51;
|
||||
/// Writes a signed copy; the document is untouched.
|
||||
pub const SIGN: u8 = 52;
|
||||
/// How many fields hold a signature; nothing checked.
|
||||
pub const SIGNED: u8 = 53;
|
||||
pub const EXPORT_IMAGE: u8 = 54;
|
||||
pub const EXPORT_TEXT: u8 = 55;
|
||||
/// Writes a copy compressed and downsampled.
|
||||
pub const OPTIMIZE: u8 = 56;
|
||||
/// Writes a copy to print; needs leave to print.
|
||||
pub const PRINT_COPY: u8 = 57;
|
||||
/// What MuPDF warned when it repaired the file.
|
||||
pub const REPAIR_REPORT: u8 = 58;
|
||||
/// Decodes every picture; answers whether any failed.
|
||||
pub const CHECK_PICTURES: u8 = 59;
|
||||
pub const OK: u8 = 100;
|
||||
pub const ERR: u8 = 101;
|
||||
}
|
||||
|
||||
/// What a document allows, as Ely's bits.
|
||||
pub mod may {
|
||||
pub const PRINT: u32 = 1;
|
||||
pub const COPY: u32 = 2;
|
||||
pub const EDIT: u32 = 4;
|
||||
pub const ANNOTATE: u32 = 8;
|
||||
pub const FILL: u32 = 16;
|
||||
pub const ASSEMBLE: u32 = 32;
|
||||
/// Opened with the owner password, or not protected.
|
||||
pub const OWNER: u32 = 64;
|
||||
pub const NAMES: [(u32, &str); 7] = [
|
||||
(PRINT, "print"),
|
||||
(COPY, "copy"),
|
||||
(EDIT, "edit"),
|
||||
(ANNOTATE, "annotate"),
|
||||
(FILL, "fill"),
|
||||
(ASSEMBLE, "assemble"),
|
||||
(OWNER, "owner"),
|
||||
];
|
||||
/// A refusal's error text starts with this.
|
||||
pub const REFUSED: &str = "restricted:";
|
||||
}
|
||||
|
||||
/// The stamp of the sources this was built from.
|
||||
pub const BUILD: &str = env!("ELY_PDF_BUILD");
|
||||
|
||||
/// One character on a page, in reading order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Char {
|
||||
/// Which text line it is on, counted over the page.
|
||||
pub line: i32,
|
||||
pub c: char,
|
||||
/// x0, y0, x1, y1 in page points.
|
||||
pub rect: [f32; 4],
|
||||
}
|
||||
|
||||
/// Bytes a character takes in a TEXT reply.
|
||||
pub const CHAR_BYTES: usize = 24;
|
||||
|
||||
/// Parses a TEXT reply: line, code point, four box floats.
|
||||
pub fn chars(bytes: &[u8]) -> io::Result<Vec<Char>> {
|
||||
if !bytes.len().is_multiple_of(CHAR_BYTES) {
|
||||
return Err(io::Error::other("a text reply of odd length"));
|
||||
}
|
||||
let word =
|
||||
|at: &[u8], n: usize| <[u8; 4]>::try_from(&at[n * 4..n * 4 + 4]).expect("four bytes");
|
||||
bytes
|
||||
.chunks_exact(CHAR_BYTES)
|
||||
.map(|one| {
|
||||
let code = u32::from_ne_bytes(word(one, 1));
|
||||
Ok(Char {
|
||||
line: i32::from_ne_bytes(word(one, 0)),
|
||||
c: char::from_u32(code)
|
||||
.ok_or_else(|| io::Error::other(format!("no character {code}")))?,
|
||||
rect: [2, 3, 4, 5].map(|n| f32::from_ne_bytes(word(one, n))),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a SEARCH reply: per hit, count then quads.
|
||||
pub fn hits(bytes: &[u8]) -> io::Result<Vec<Vec<[f32; 8]>>> {
|
||||
let short = || io::Error::other("a short search reply");
|
||||
let (mut hits, mut at) = (Vec::new(), 0);
|
||||
let word = |at: &mut usize| -> io::Result<[u8; 4]> {
|
||||
let four = bytes.get(*at..*at + 4).ok_or_else(short)?;
|
||||
*at += 4;
|
||||
Ok(four.try_into().expect("four bytes"))
|
||||
};
|
||||
while at < bytes.len() {
|
||||
let count = usize::try_from(i32::from_ne_bytes(word(&mut at)?)).map_err(|_| short())?;
|
||||
let mut quads = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let mut quad = [0f32; 8];
|
||||
for value in &mut quad {
|
||||
*value = f32::from_ne_bytes(word(&mut at)?);
|
||||
}
|
||||
quads.push(quad);
|
||||
}
|
||||
hits.push(quads);
|
||||
}
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// One bookmark and those under it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Bookmark {
|
||||
pub title: String,
|
||||
/// Its page from zero; none for outside links.
|
||||
pub page: Option<usize>,
|
||||
/// Where on that page, in points from the top.
|
||||
pub y: f32,
|
||||
pub down: Vec<Bookmark>,
|
||||
}
|
||||
|
||||
fn bookmark(value: &Value) -> io::Result<Bookmark> {
|
||||
let bad = || io::Error::other(format!("a bad bookmark {value}"));
|
||||
let page = value["page"].as_i64().ok_or_else(bad)?;
|
||||
Ok(Bookmark {
|
||||
title: value["title"].as_str().ok_or_else(bad)?.to_string(),
|
||||
page: usize::try_from(page).ok(),
|
||||
y: value["y"].as_f64().ok_or_else(bad)? as f32,
|
||||
down: bookmarks(&value["down"])?,
|
||||
})
|
||||
}
|
||||
|
||||
fn bookmarks(value: &Value) -> io::Result<Vec<Bookmark>> {
|
||||
let list = value
|
||||
.as_array()
|
||||
.ok_or_else(|| io::Error::other(format!("no bookmark list in {value}")))?;
|
||||
list.iter().map(bookmark).collect()
|
||||
}
|
||||
|
||||
/// Parses an OUTLINE reply.
|
||||
pub fn outline(json: &str) -> io::Result<Vec<Bookmark>> {
|
||||
bookmarks(&serde_json::from_str(json).map_err(io::Error::other)?)
|
||||
}
|
||||
|
||||
/// A request for whatever state the document is in.
|
||||
pub const ANY: u64 = u64::MAX;
|
||||
/// How a refusal for a changed document begins.
|
||||
pub const STALE: &str = "stale: ";
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Removal and cleaning into new files (PDF-09, PDF-10).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// A region to remove, in page points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Region {
|
||||
pub page: usize,
|
||||
pub rect: [f32; 4],
|
||||
}
|
||||
|
||||
/// The written file's check: zeros, and one version.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Redacted {
|
||||
pub regions: usize,
|
||||
/// Characters still inside a region.
|
||||
pub text: usize,
|
||||
/// Pixels inside a region that are not black.
|
||||
pub light: usize,
|
||||
/// Drawings still inside a region.
|
||||
pub objects: usize,
|
||||
pub versions: usize,
|
||||
}
|
||||
|
||||
/// What cleaning reaches, or what is left after.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Reach {
|
||||
/// Info entries, plus one for an XMP stream.
|
||||
pub meta: usize,
|
||||
pub annots: usize,
|
||||
pub files: usize,
|
||||
/// Pages with hidden text or layers.
|
||||
pub hidden: usize,
|
||||
pub scripts: usize,
|
||||
pub hidden_annots: usize,
|
||||
}
|
||||
|
||||
/// What to clean.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Cleaning {
|
||||
pub meta: bool,
|
||||
pub annots: bool,
|
||||
pub files: bool,
|
||||
/// Invisible text, scripts and hidden annotations.
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
impl Cleaning {
|
||||
fn mask(self) -> u64 {
|
||||
u64::from(self.meta) | u64::from(self.annots) << 1 | u64::from(self.files) << 2 | u64::from(self.hidden) << 3
|
||||
}
|
||||
}
|
||||
|
||||
fn count(value: &Value, key: &str) -> io::Result<usize> {
|
||||
let n = value[key].as_u64().ok_or_else(|| io::Error::other(format!("a bad {key} in {value}")))?;
|
||||
usize::try_from(n).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
fn reach(bytes: &[u8]) -> io::Result<Reach> {
|
||||
let value: Value = serde_json::from_slice(bytes).map_err(io::Error::other)?;
|
||||
Ok(Reach {
|
||||
meta: count(&value, "meta")?,
|
||||
annots: count(&value, "annots")?,
|
||||
files: count(&value, "files")?,
|
||||
hidden: count(&value, "hidden")?,
|
||||
scripts: count(&value, "scripts")?,
|
||||
hidden_annots: count(&value, "hidden_annots")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn utf8(path: &Path) -> io::Result<&str> {
|
||||
path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// Writes `out` without `regions`; an unclean result is an error.
|
||||
pub fn redact(&mut self, doc: Doc, regions: &[Region], out: &Path) -> io::Result<Redacted> {
|
||||
let spec: Vec<Value> = regions.iter().map(|r| json!({ "page": r.page, "rect": r.rect })).collect();
|
||||
let ask = self.on(&doc)?.str(&Value::Array(spec).to_string()).str(utf8(out)?);
|
||||
let value: Value = serde_json::from_slice(&self.request(tag::REDACT, &ask.bytes())?).map_err(io::Error::other)?;
|
||||
Ok(Redacted {
|
||||
regions: count(&value, "regions")?,
|
||||
text: count(&value, "text")?,
|
||||
light: count(&value, "light")?,
|
||||
objects: count(&value, "objects")?,
|
||||
versions: count(&value, "versions")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Counts what cleaning reaches; `scratch` holds a passing copy.
|
||||
pub fn clean_scan(&mut self, doc: Doc, scratch: &Path) -> io::Result<Reach> {
|
||||
reach(&self.request(tag::CLEAN_SCAN, &self.whole(&doc)?.str(utf8(scratch)?).bytes())?)
|
||||
}
|
||||
|
||||
/// Writes `out` cleaned; what is left in it.
|
||||
pub fn clean(&mut self, doc: Doc, what: Cleaning, out: &Path) -> io::Result<Reach> {
|
||||
let ask = self.whole(&doc)?.u64(what.mask()).str(utf8(out)?);
|
||||
reach(&self.request(tag::CLEAN, &ask.bytes())?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Small PDFs written by hand, for tests.
|
||||
|
||||
/// A bookmark: title, page, and those under it.
|
||||
pub type Mark<'a> = (&'a str, usize, &'a [(&'a str, usize)]);
|
||||
|
||||
/// Links, form fields and page labels to add.
|
||||
#[derive(Default)]
|
||||
pub struct Extras<'a> {
|
||||
/// Link annotations: from page, to page.
|
||||
pub links: &'a [(usize, usize)],
|
||||
/// A text field on each of these pages.
|
||||
pub fields: &'a [usize],
|
||||
/// Page labels: first page, style name.
|
||||
pub labels: &'a [(usize, &'a str)],
|
||||
/// Fields leave out their optional /P.
|
||||
pub loose: bool,
|
||||
/// Every page's /UserUnit, if not one.
|
||||
pub unit: Option<f32>,
|
||||
/// Field names start with this, else "field".
|
||||
pub prefix: Option<&'a str>,
|
||||
/// The form's look: red 24 pt; font key, DA token.
|
||||
pub look: Option<(&'a str, &'a str)>,
|
||||
/// The form's default alignment, /Q.
|
||||
pub align: Option<u8>,
|
||||
/// Page one: a form of this text at 300, 400.
|
||||
pub stamp: Option<&'a str>,
|
||||
/// Page one: a 2x2 image, 100x50 at 72, 500.
|
||||
pub picture: bool,
|
||||
/// Fields hang under one parent of this name.
|
||||
pub parent: Option<&'a str>,
|
||||
/// A parent and child field with no widget.
|
||||
pub bare: Option<(&'a str, &'a str)>,
|
||||
/// Page one: a file annotation, name and bytes, no /Size.
|
||||
pub attached: Option<(&'a str, &'a [u8])>,
|
||||
}
|
||||
|
||||
/// A PDF of `pages` (width, height, lines), with bookmarks.
|
||||
pub fn pdf(pages: &[(f32, f32, &[&str])], marks: &[Mark]) -> Vec<u8> {
|
||||
pdf_with(pages, marks, &Extras::default())
|
||||
}
|
||||
|
||||
/// A PDF as `pdf` makes, with `extras`.
|
||||
pub fn pdf_with(pages: &[(f32, f32, &[&str])], marks: &[Mark], extras: &Extras) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = Vec::new();
|
||||
let page_ids: Vec<usize> = (0..pages.len()).map(|n| 4 + n * 2).collect();
|
||||
// Links, then fields, after the outline objects.
|
||||
let outline_objs: usize = marks.iter().map(|(_, _, down)| 1 + down.len()).sum();
|
||||
let first_extra = 4 + pages.len() * 2 + 1 + outline_objs;
|
||||
let field_ids: Vec<usize> = (0..extras.fields.len()).map(|n| first_extra + extras.links.len() + n).collect();
|
||||
let mut catalog = String::from("<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R");
|
||||
// The parent field comes last of all.
|
||||
let parent_id = first_extra + extras.links.len() + extras.fields.len() + usize::from(extras.stamp.is_some()) + usize::from(extras.picture);
|
||||
let bare_id = parent_id + usize::from(extras.parent.is_some());
|
||||
let attach_id = bare_id + 2 * usize::from(extras.bare.is_some());
|
||||
if !field_ids.is_empty() || extras.bare.is_some() {
|
||||
let mut refs: Vec<String> = match extras.parent {
|
||||
Some(_) => vec![format!("{parent_id} 0 R")],
|
||||
None => field_ids.iter().map(|id| format!("{id} 0 R")).collect(),
|
||||
};
|
||||
refs.extend(extras.bare.map(|_| format!("{bare_id} 0 R")));
|
||||
let font = 4 + pages.len() * 2;
|
||||
let look = extras.look.map_or_else(String::new, |(key, token)| format!(" /DA (/{token} 24 Tf 1 0 0 rg) /DR << /Font << /{key} {font} 0 R >> >>"));
|
||||
let align = extras.align.map_or_else(String::new, |q| format!(" /Q {q}"));
|
||||
catalog.push_str(&format!(" /AcroForm << /Fields [{}]{look}{align} >>", refs.join(" ")));
|
||||
}
|
||||
if !extras.labels.is_empty() {
|
||||
let nums: Vec<String> = extras.labels.iter().map(|(at, style)| format!("{at} << /S /{style} >>")).collect();
|
||||
catalog.push_str(&format!(" /PageLabels << /Nums [{}] >>", nums.join(" ")));
|
||||
}
|
||||
objs.push(format!("{catalog} >>").into_bytes());
|
||||
let kids: Vec<String> = page_ids.iter().map(|id| format!("{id} 0 R")).collect();
|
||||
objs.push(
|
||||
format!(
|
||||
"<< /Type /Pages /Kids [{}] /Count {} >>",
|
||||
kids.join(" "),
|
||||
pages.len()
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
// Outline objects follow the pages.
|
||||
let mut id = 4 + pages.len() * 2 + 1;
|
||||
let tops: Vec<usize> = (marks.iter())
|
||||
.map(|(_, _, down)| {
|
||||
let top = id;
|
||||
id += 1 + down.len();
|
||||
top
|
||||
})
|
||||
.collect();
|
||||
objs.push(match (tops.first(), tops.last()) {
|
||||
(Some(first), Some(last)) => format!(
|
||||
"<< /Type /Outlines /First {first} 0 R /Last {last} 0 R /Count {} >>",
|
||||
tops.len()
|
||||
)
|
||||
.into_bytes(),
|
||||
_ => b"<< /Type /Outlines /Count 0 >>".to_vec(),
|
||||
});
|
||||
for (n, (w, h, lines)) in pages.iter().enumerate() {
|
||||
let mut stream = String::from("BT /F1 18 Tf 72 0 Td\n");
|
||||
for (at, line) in lines.iter().enumerate() {
|
||||
let y = h - 72.0 - at as f32 * 24.0;
|
||||
stream.push_str(&format!("1 0 0 1 72 {y} Tm ({line}) Tj\n"));
|
||||
}
|
||||
stream.push_str("ET");
|
||||
let font = 4 + pages.len() * 2;
|
||||
let (drawn, xobjects) = match n {
|
||||
0 => xobjects(extras, first_extra + extras.links.len() + extras.fields.len()),
|
||||
_ => (String::new(), String::new()),
|
||||
};
|
||||
stream.push_str(&drawn);
|
||||
let links = (extras.links.iter().enumerate()).filter(|(_, (from, _))| *from == n).map(|(at, _)| first_extra + at);
|
||||
let fields = (extras.fields.iter().enumerate()).filter(|(_, page)| **page == n).map(|(at, _)| field_ids[at]);
|
||||
let attached = extras.attached.filter(|_| n == 0).map(|_| attach_id);
|
||||
let annots: Vec<String> = links.chain(fields).chain(attached).map(|id| format!("{id} 0 R")).collect();
|
||||
let unit = extras.unit.map_or_else(String::new, |unit| format!(" /UserUnit {unit}"));
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}]{unit} /Contents {} 0 R /Resources << /Font << /F1 {font} 0 R >>{xobjects} >> /Annots [{}] >>", page_ids[n] + 1, annots.join(" ")).into_bytes());
|
||||
objs.push(
|
||||
format!(
|
||||
"<< /Length {} >>\nstream\n{stream}\nendstream",
|
||||
stream.len()
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
}
|
||||
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
|
||||
// Top marks link to each other; children hang under them.
|
||||
for (at, (title, page, down)) in marks.iter().enumerate() {
|
||||
let top = tops[at];
|
||||
let mut dict = format!(
|
||||
"<< /Title ({title}) /Parent 3 0 R /Dest [{} 0 R /XYZ 0 700 0]",
|
||||
page_ids[*page]
|
||||
);
|
||||
if at > 0 {
|
||||
dict.push_str(&format!(" /Prev {} 0 R", tops[at - 1]));
|
||||
}
|
||||
if at + 1 < tops.len() {
|
||||
dict.push_str(&format!(" /Next {} 0 R", tops[at + 1]));
|
||||
}
|
||||
if !down.is_empty() {
|
||||
dict.push_str(&format!(
|
||||
" /First {} 0 R /Last {} 0 R /Count {}",
|
||||
top + 1,
|
||||
top + down.len(),
|
||||
down.len()
|
||||
));
|
||||
}
|
||||
objs.push(format!("{dict} >>").into_bytes());
|
||||
for (n, (title, page)) in down.iter().enumerate() {
|
||||
let mut dict = format!(
|
||||
"<< /Title ({title}) /Parent {top} 0 R /Dest [{} 0 R /XYZ 0 500 0]",
|
||||
page_ids[*page]
|
||||
);
|
||||
if n > 0 {
|
||||
dict.push_str(&format!(" /Prev {} 0 R", top + n));
|
||||
}
|
||||
if n + 1 < down.len() {
|
||||
dict.push_str(&format!(" /Next {} 0 R", top + n + 2));
|
||||
}
|
||||
objs.push(format!("{dict} >>").into_bytes());
|
||||
}
|
||||
}
|
||||
for (_, to) in extras.links {
|
||||
objs.push(format!("<< /Type /Annot /Subtype /Link /Rect [72 72 200 100] /Border [0 0 0] /Dest [{} 0 R /Fit] >>", page_ids[*to]).into_bytes());
|
||||
}
|
||||
for (n, page) in extras.fields.iter().enumerate() {
|
||||
let owner = if extras.loose { String::new() } else { format!(" /P {} 0 R", page_ids[*page]) };
|
||||
let name = extras.prefix.unwrap_or("field");
|
||||
let parent = extras.parent.map_or_else(String::new, |_| format!(" /Parent {parent_id} 0 R"));
|
||||
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}{n}) /V (value{n}) /Rect [72 120 300 140]{owner}{parent} >>").into_bytes());
|
||||
}
|
||||
if let Some(text) = extras.stamp {
|
||||
let font = 4 + pages.len() * 2;
|
||||
let content = format!("BT /F1 12 Tf 0 10 Td ({text}) Tj ET");
|
||||
objs.push(format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 40] /Resources << /Font << /F1 {font} 0 R >> >> /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes());
|
||||
}
|
||||
if extras.picture {
|
||||
let mut image = b"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n".to_vec();
|
||||
image.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
|
||||
image.extend_from_slice(b"\nendstream");
|
||||
objs.push(image);
|
||||
}
|
||||
if let Some(name) = extras.parent {
|
||||
let kids: Vec<String> = field_ids.iter().map(|id| format!("{id} 0 R")).collect();
|
||||
objs.push(format!("<< /T ({name}) /Kids [{}] >>", kids.join(" ")).into_bytes());
|
||||
}
|
||||
if let Some((parent, child)) = extras.bare {
|
||||
objs.push(format!("<< /T ({parent}) /Kids [{} 0 R] >>", bare_id + 1).into_bytes());
|
||||
objs.push(format!("<< /T ({child}) /FT /Tx /V (Alice) /Parent {bare_id} 0 R >>").into_bytes());
|
||||
}
|
||||
if let Some((name, bytes)) = extras.attached {
|
||||
objs.push(format!("<< /Type /Annot /Subtype /FileAttachment /Rect [72 72 90 90] /FS {} 0 R >>", attach_id + 1).into_bytes());
|
||||
objs.push(format!("<< /Type /Filespec /F ({name}) /UF ({name}) /EF << /F {} 0 R >> >>", attach_id + 2).into_bytes());
|
||||
let mut file = format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", bytes.len()).into_bytes();
|
||||
file.extend_from_slice(bytes);
|
||||
file.extend_from_slice(b"\nendstream");
|
||||
objs.push(file);
|
||||
}
|
||||
file(&objs)
|
||||
}
|
||||
|
||||
/// Page one's form and image draws, and their resources.
|
||||
fn xobjects(extras: &Extras, first: usize) -> (String, String) {
|
||||
let (mut drawn, mut names, mut id) = (String::new(), String::new(), first);
|
||||
if extras.stamp.is_some() {
|
||||
drawn.push_str("\nq 1 0 0 1 300 400 cm /Fm1 Do Q");
|
||||
names.push_str(&format!(" /Fm1 {id} 0 R"));
|
||||
id += 1;
|
||||
}
|
||||
if extras.picture {
|
||||
drawn.push_str("\nq 100 0 0 50 72 500 cm /Im1 Do Q");
|
||||
names.push_str(&format!(" /Im1 {id} 0 R"));
|
||||
}
|
||||
match names.is_empty() {
|
||||
true => (drawn, names),
|
||||
false => (drawn, format!(" /XObject <<{names} >>")),
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of `size` points: a gray picture only.
|
||||
pub fn scan((w, h): (f32, f32), (pw, ph): (u32, u32), gray: &[u8]) -> Vec<u8> {
|
||||
let content = format!("q {w} 0 0 {h} 0 0 cm /Im1 Do Q");
|
||||
let mut image = format!("<< /Type /XObject /Subtype /Image /Width {pw} /Height {ph} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >>\nstream\n", gray.len()).into_bytes();
|
||||
image.extend_from_slice(gray);
|
||||
image.extend_from_slice(b"\nendstream");
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>").into_bytes(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
|
||||
image,
|
||||
];
|
||||
file(&objs)
|
||||
}
|
||||
|
||||
/// Numbered objects as a whole PDF file.
|
||||
fn file(objs: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut body = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (n, obj) in objs.iter().enumerate() {
|
||||
offsets.push(body.len());
|
||||
body.extend_from_slice(format!("{} 0 obj\n", n + 1).as_bytes());
|
||||
body.extend_from_slice(obj);
|
||||
body.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = body.len();
|
||||
body.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
body.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
body.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
/// One Letter page drawing `content`; `objects` are numbered from 5.
|
||||
pub fn one_page(catalog: &str, page: &str, content: &str, objects: &[String]) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R{catalog} >>").into_bytes(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R{page} >>").into_bytes(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(),
|
||||
];
|
||||
objs.extend(objects.iter().map(|obj| obj.clone().into_bytes()));
|
||||
file(&objs)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Permissions, owner access and protected copies (PDF-11).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_ipc::Unpack;
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// A protected copy's passwords and what it allows.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Lock {
|
||||
/// Empty opens without asking.
|
||||
pub user: String,
|
||||
pub owner: String,
|
||||
/// `proto::may` bits.
|
||||
pub allow: u32,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// `proto::may` bits for `doc` now.
|
||||
pub fn permissions(&mut self, doc: Doc) -> io::Result<u32> {
|
||||
let reply = self.request(tag::PERMISSIONS, &self.whole(&doc)?.bytes())?;
|
||||
Ok(Unpack::new(&reply).u64()? as u32)
|
||||
}
|
||||
|
||||
/// Tries an owner password; false keeps the old access.
|
||||
pub fn owner(&mut self, doc: Doc, password: &str) -> io::Result<bool> {
|
||||
let reply = self.request(tag::OWNER, &self.whole(&doc)?.str(password).bytes())?;
|
||||
Ok(Unpack::new(&reply).u64()? != 0)
|
||||
}
|
||||
|
||||
/// Writes `out` with `lock`, or with no protection.
|
||||
pub fn protect(&mut self, doc: Doc, out: &Path, lock: Option<&Lock>) -> io::Result<()> {
|
||||
let out = out.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
let (user, owner, allow) = lock.map_or(("", "", 0), |lock| (lock.user.as_str(), lock.owner.as_str(), lock.allow));
|
||||
let ask = self.whole(&doc)?.str(out).u64(u64::from(lock.is_some())).str(user).str(owner).u64(u64::from(allow));
|
||||
self.request(tag::PROTECT, &ask.bytes()).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Signatures: listed with their checks, and new ones (PDF-12).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// Whether the signed bytes are as signed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Digest {
|
||||
Intact,
|
||||
Changed,
|
||||
/// No certificate, or a signature that will not read.
|
||||
#[default]
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// One signature field, signed or empty.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Signature {
|
||||
/// None for a field drawn on no page.
|
||||
pub page: Option<usize>,
|
||||
pub object: u32,
|
||||
pub rect: [f32; 4],
|
||||
pub name: String,
|
||||
pub signed: Option<Signed>,
|
||||
}
|
||||
|
||||
/// What a signed field says and how it checks.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Signed {
|
||||
pub digest: Digest,
|
||||
/// Updates were saved after it.
|
||||
pub later: bool,
|
||||
/// Those updates go past what it allows.
|
||||
pub broken: bool,
|
||||
pub signer: String,
|
||||
pub common: String,
|
||||
/// The PDF date the signer claims.
|
||||
pub date: String,
|
||||
pub reason: String,
|
||||
pub location: String,
|
||||
/// DER, the signer's first.
|
||||
pub certs: Vec<Vec<u8>>,
|
||||
/// Why it could not be read.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A new signature: a field, a certificate, words.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Signing {
|
||||
pub page: usize,
|
||||
/// An empty field's object, or a new field at `rect`.
|
||||
pub object: Option<u32>,
|
||||
pub rect: [f32; 4],
|
||||
pub name: String,
|
||||
/// A PKCS #12 file and its password.
|
||||
pub pfx: String,
|
||||
pub pfx_password: String,
|
||||
pub reason: String,
|
||||
pub location: String,
|
||||
}
|
||||
|
||||
fn bad(what: &str, value: &Value) -> io::Error {
|
||||
io::Error::other(format!("a bad {what} in {value}"))
|
||||
}
|
||||
|
||||
fn text(value: &Value, key: &str) -> io::Result<String> {
|
||||
value[key].as_str().map(str::to_string).ok_or_else(|| bad(key, value))
|
||||
}
|
||||
|
||||
fn hex(value: &Value) -> io::Result<Vec<u8>> {
|
||||
let digits = value.as_str().filter(|s| s.len() % 2 == 0).ok_or_else(|| bad("certificate", value))?;
|
||||
(0..digits.len()).step_by(2).map(|at| u8::from_str_radix(&digits[at..at + 2], 16).map_err(io::Error::other)).collect()
|
||||
}
|
||||
|
||||
fn signed(value: &Value) -> io::Result<Signed> {
|
||||
let digest = match value["digest"].as_str() {
|
||||
Some("ok") => Digest::Intact,
|
||||
Some("changed") => Digest::Changed,
|
||||
Some("no_certificate" | "unreadable") => Digest::Unreadable,
|
||||
_ => return Err(bad("digest", value)),
|
||||
};
|
||||
if let Some(error) = value["error"].as_str() {
|
||||
return Ok(Signed { digest, error: Some(error.into()), ..Signed::default() });
|
||||
}
|
||||
let certs = value["certs"].as_array().ok_or_else(|| bad("certs", value))?.iter().map(hex).collect::<io::Result<_>>()?;
|
||||
Ok(Signed {
|
||||
digest,
|
||||
later: value["later"].as_i64().ok_or_else(|| bad("later", value))? != 0,
|
||||
broken: value["broken"].as_i64().ok_or_else(|| bad("broken", value))? != 0,
|
||||
signer: text(value, "signer")?,
|
||||
common: text(value, "common")?,
|
||||
date: text(value, "date")?,
|
||||
reason: text(value, "reason")?,
|
||||
location: text(value, "location")?,
|
||||
certs,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn signature(value: &Value) -> io::Result<Signature> {
|
||||
let rect = value["rect"].as_array().filter(|r| r.len() == 4).ok_or_else(|| bad("rect", value))?;
|
||||
let mut out = [0f32; 4];
|
||||
for (at, side) in rect.iter().enumerate() {
|
||||
out[at] = side.as_f64().ok_or_else(|| bad("rect", value))? as f32;
|
||||
}
|
||||
Ok(Signature {
|
||||
page: usize::try_from(value["page"].as_i64().ok_or_else(|| bad("page", value))?).ok(),
|
||||
object: value["object"].as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad("object", value))?,
|
||||
rect: out,
|
||||
name: text(value, "name")?,
|
||||
signed: match value["signed"].as_bool().ok_or_else(|| bad("signed", value))? {
|
||||
true => Some(signed(value)?),
|
||||
false => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// Every signature field in the file at `path`, checked.
|
||||
pub fn signatures(&mut self, doc: Doc, path: &Path) -> io::Result<Vec<Signature>> {
|
||||
let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
let reply = self.request(tag::SIGNATURES, &self.whole(&doc)?.str(path).bytes())?;
|
||||
let value: Value = serde_json::from_slice(&reply).map_err(io::Error::other)?;
|
||||
value.as_array().ok_or_else(|| bad("list", &value))?.iter().map(signature).collect()
|
||||
}
|
||||
|
||||
/// Fields holding a signature, counted without checking.
|
||||
pub fn signed(&mut self, doc: Doc) -> io::Result<usize> {
|
||||
let reply = self.request(tag::SIGNED, &self.whole(&doc)?.bytes())?;
|
||||
Ok(ely_ipc::Unpack::new(&reply).u64()? as usize)
|
||||
}
|
||||
|
||||
/// Writes `out` signed by an update; `doc` is untouched.
|
||||
pub fn sign(&mut self, doc: Doc, out: &Path, how: &Signing) -> io::Result<()> {
|
||||
let out = out.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
let object = how.object.map_or(-1, i64::from);
|
||||
let mut ask = self.on(&doc)?.str(out).u64(how.page as u64).i64(object);
|
||||
for side in how.rect {
|
||||
ask = ask.u64(u64::from(side.to_bits()));
|
||||
}
|
||||
let ask = ask.str(&how.name).str(&how.pfx).str(&how.pfx_password).str(&how.reason).str(&how.location);
|
||||
self.request(tag::SIGN, &ask.bytes()).map(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Bookmarks, links, properties, attachments (PDF-07).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_ipc::Unpack;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
|
||||
/// Where a bookmark or link goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Target {
|
||||
/// A page and a point down it.
|
||||
Page(usize, f32),
|
||||
/// An http, https or mailto address.
|
||||
Web(String),
|
||||
}
|
||||
|
||||
/// A bookmark change; paths count children from the top.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BookmarkChange {
|
||||
/// Before the bookmark at `path`, or last past the end.
|
||||
Add { path: Vec<usize>, title: String, page: usize, y: f32 },
|
||||
/// `expect` is its title as read; another refuses.
|
||||
Edit { path: Vec<usize>, expect: String, title: Option<String>, to: Option<(usize, f32)> },
|
||||
Delete { path: Vec<usize>, expect: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Link {
|
||||
pub rect: [f32; 4],
|
||||
pub uri: String,
|
||||
/// The page inside the document, if any.
|
||||
pub page: Option<usize>,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// A link change; `expect` is the address as read.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkChange {
|
||||
Add { rect: [f32; 4], to: Target },
|
||||
Edit { index: usize, expect: String, to: Target },
|
||||
Delete { index: usize, expect: String },
|
||||
}
|
||||
|
||||
/// The document's properties; none when the file has none.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Properties {
|
||||
pub title: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
pub keywords: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub producer: Option<String>,
|
||||
pub created: Option<String>,
|
||||
pub modified: Option<String>,
|
||||
}
|
||||
|
||||
/// An embedded file; dates in seconds, if known.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
/// Names it for reading and removing.
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
/// The page whose annotation holds it, if any.
|
||||
pub page: Option<usize>,
|
||||
pub size: u64,
|
||||
pub created: Option<i64>,
|
||||
pub modified: Option<i64>,
|
||||
pub mime: String,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
fn bad(what: &str, value: &Value) -> io::Error {
|
||||
io::Error::other(format!("a bad {what} {value}"))
|
||||
}
|
||||
|
||||
fn target(spec: &mut Value, to: &Target) {
|
||||
match to {
|
||||
Target::Page(page, y) => {
|
||||
spec["page"] = json!(page);
|
||||
spec["y"] = json!(y);
|
||||
}
|
||||
Target::Web(uri) => spec["uri"] = json!(uri),
|
||||
}
|
||||
}
|
||||
|
||||
fn parsed(bytes: &[u8]) -> io::Result<Value> {
|
||||
serde_json::from_slice(bytes).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn change_bookmark(&mut self, doc: Doc, change: &BookmarkChange) -> io::Result<String> {
|
||||
let spec = match change {
|
||||
BookmarkChange::Add { path, title, page, y } => json!({ "op": "add", "path": path, "title": title, "page": page, "y": y }),
|
||||
BookmarkChange::Edit { path, expect, title, to } => {
|
||||
let mut spec = json!({ "op": "edit", "path": path, "expect": expect });
|
||||
if let Some(title) = title {
|
||||
spec["title"] = json!(title);
|
||||
}
|
||||
if let Some((page, y)) = to {
|
||||
target(&mut spec, &Target::Page(*page, *y));
|
||||
}
|
||||
spec
|
||||
}
|
||||
BookmarkChange::Delete { path, expect } => json!({ "op": "delete", "path": path, "expect": expect }),
|
||||
};
|
||||
let ask = self.on(&doc)?.str(&spec.to_string());
|
||||
Unpack::new(&self.request(tag::OUTLINE_EDIT, &ask.bytes())?).str()
|
||||
}
|
||||
|
||||
pub fn links(&mut self, doc: Doc, page: usize) -> io::Result<Vec<Link>> {
|
||||
let reply = parsed(&self.request(tag::LINKS, &self.on(&doc)?.u64(page as u64).bytes())?)?;
|
||||
let list = reply.as_array().ok_or_else(|| bad("link list", &reply))?;
|
||||
list.iter()
|
||||
.map(|value| {
|
||||
let rect = value["rect"].as_array().filter(|r| r.len() == 4).ok_or_else(|| bad("link", value))?;
|
||||
let mut out = [0f32; 4];
|
||||
for (at, side) in rect.iter().enumerate() {
|
||||
out[at] = side.as_f64().ok_or_else(|| bad("link", value))? as f32;
|
||||
}
|
||||
Ok(Link {
|
||||
rect: out,
|
||||
uri: value["uri"].as_str().ok_or_else(|| bad("link", value))?.to_string(),
|
||||
page: usize::try_from(value["page"].as_i64().ok_or_else(|| bad("link", value))?).ok(),
|
||||
y: value["y"].as_f64().ok_or_else(|| bad("link", value))? as f32,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn change_link(&mut self, doc: Doc, page: usize, change: &LinkChange) -> io::Result<String> {
|
||||
let spec = match change {
|
||||
LinkChange::Add { rect, to } => {
|
||||
let mut spec = json!({ "op": "add", "rect": rect });
|
||||
target(&mut spec, to);
|
||||
spec
|
||||
}
|
||||
LinkChange::Edit { index, expect, to } => {
|
||||
let mut spec = json!({ "op": "edit", "index": index, "expect": expect });
|
||||
target(&mut spec, to);
|
||||
spec
|
||||
}
|
||||
LinkChange::Delete { index, expect } => json!({ "op": "delete", "index": index, "expect": expect }),
|
||||
};
|
||||
let ask = self.on(&doc)?.u64(page as u64).str(&spec.to_string());
|
||||
Unpack::new(&self.request(tag::LINK_EDIT, &ask.bytes())?).str()
|
||||
}
|
||||
|
||||
pub fn properties(&mut self, doc: Doc) -> io::Result<Properties> {
|
||||
let reply = parsed(&self.request(tag::METADATA, &self.whole(&doc)?.bytes())?)?;
|
||||
let field = |key: &str| match &reply[key] {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(text) => Ok(Some(text.clone())),
|
||||
other => Err(bad(key, other)),
|
||||
};
|
||||
Ok(Properties {
|
||||
title: field("Title")?,
|
||||
author: field("Author")?,
|
||||
subject: field("Subject")?,
|
||||
keywords: field("Keywords")?,
|
||||
creator: field("Creator")?,
|
||||
producer: field("Producer")?,
|
||||
created: field("CreationDate")?,
|
||||
modified: field("ModDate")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets Title, Author, Subject or Keywords, one step.
|
||||
pub fn set_properties(&mut self, doc: Doc, changes: &[(&str, &str)]) -> io::Result<String> {
|
||||
let spec: serde_json::Map<String, Value> = changes.iter().map(|(key, value)| (key.to_string(), json!(value))).collect();
|
||||
let ask = self.whole(&doc)?.str(&Value::Object(spec).to_string());
|
||||
Unpack::new(&self.request(tag::SET_METADATA, &ask.bytes())?).str()
|
||||
}
|
||||
|
||||
pub fn attachments(&mut self, doc: Doc) -> io::Result<Vec<Attachment>> {
|
||||
let reply = parsed(&self.request(tag::ATTACHMENTS, &self.whole(&doc)?.bytes())?)?;
|
||||
let list = reply.as_array().ok_or_else(|| bad("attachment list", &reply))?;
|
||||
list.iter()
|
||||
.map(|value| {
|
||||
let text = |key: &str| value[key].as_str().map(str::to_string).ok_or_else(|| bad(key, value));
|
||||
let date = |key: &str| value[key].as_i64().map(|at| (at >= 0).then_some(at)).ok_or_else(|| bad(key, value));
|
||||
Ok(Attachment {
|
||||
key: text("key")?,
|
||||
name: text("name")?,
|
||||
page: usize::try_from(value["page"].as_i64().ok_or_else(|| bad("page", value))?).ok(),
|
||||
size: value["size"].as_u64().ok_or_else(|| bad("size", value))?,
|
||||
created: date("created")?,
|
||||
modified: date("modified")?,
|
||||
mime: text("type")?,
|
||||
note: text("note")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn attachment(&mut self, doc: Doc, key: &str) -> io::Result<Vec<u8>> {
|
||||
self.request(tag::ATTACHMENT, &self.on(&doc)?.str(key).bytes())
|
||||
}
|
||||
|
||||
/// Embeds the file at `path` as `name`; the mark.
|
||||
pub fn attach(&mut self, doc: Doc, name: &str, path: &Path) -> io::Result<String> {
|
||||
let path = path.to_str().ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
|
||||
Unpack::new(&self.request(tag::ATTACH, &self.whole(&doc)?.str(name).str(path).bytes())?).str()
|
||||
}
|
||||
|
||||
pub fn detach(&mut self, doc: Doc, key: &str) -> io::Result<String> {
|
||||
Unpack::new(&self.request(tag::DETACH, &self.on(&doc)?.str(key).bytes())?).str()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Text and object edits against the real worker (PDF-03, PDF-04).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Worker, Writing, Wrote,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// A Letter page: two lines, a stamp, a picture.
|
||||
fn opened(dir: &Path) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
let extras = Extras { stamp: Some("STAMP"), picture: true, ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn page_text(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
/// Rectangles equal to a hundredth of a point.
|
||||
fn near(got: [f32; 4], want: [f32; 4]) {
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{got:?} is not {want:?}");
|
||||
}
|
||||
|
||||
/// A rewrite of block `block` as `text` in `font`.
|
||||
fn rewrite(worker: &mut Worker, doc: Doc, block: usize, text: &str, font: &str) -> Writing {
|
||||
let read = worker.text_blocks(doc, 0).expect("blocks")[block].clone();
|
||||
Writing {
|
||||
block: Some(block),
|
||||
expected: read.text,
|
||||
text: text.into(),
|
||||
font: font.into(),
|
||||
size: read.size,
|
||||
rgb: read.rgb,
|
||||
bx: [read.rect[0], read.rect[1], read.rect[2] - read.rect[0]],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_say_what_they_hold_and_whether_they_edit() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(dir.path());
|
||||
let blocks = worker.text_blocks(doc, 0).expect("blocks");
|
||||
let hello = blocks.iter().find(|b| b.text.starts_with("Hello")).expect("the page text");
|
||||
assert!(hello.editable && hello.size == 18.0, "{hello:?}");
|
||||
let stamp = blocks.iter().find(|b| b.text == "STAMP").expect("the stamp text");
|
||||
assert!(!stamp.editable, "text inside a form is not the page's own");
|
||||
let drawn = worker.objects(doc, 0).expect("objects");
|
||||
assert_eq!(drawn.iter().map(|d| d.form).collect::<Vec<_>>(), [true, false]);
|
||||
assert_eq!(drawn[1].rect, [72.0, 242.0, 172.0, 292.0], "the picture, in page space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_text_replaces_the_old_glyphs_in_one_step() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let writing = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "helvetica");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let now = page_text(&mut worker, doc);
|
||||
assert!(now.contains("Hi world") && !now.contains("Hello"), "{now}");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), before);
|
||||
assert!(page_text(&mut worker, doc).contains("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_without_the_glyph_changes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let writing = rewrite(&mut worker, doc, index, "你好", "helvetica");
|
||||
assert_eq!(worker.put_text(doc, 0, &writing, 0.0).expect("answers"), Wrote::Missing('你'));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), before);
|
||||
assert!(page_text(&mut worker, doc).contains("Hello world"), "the old text stays");
|
||||
let chinese = Writing { font: "cjk-sc".into(), ..writing };
|
||||
assert!(matches!(worker.put_text(doc, 0, &chinese, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(page_text(&mut worker, doc).contains("你好"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_draws_the_change_and_keeps_none_of_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let mut writing = rewrite(&mut worker, doc, index, "A much longer line that will not fit the old box at all", "times");
|
||||
writing.bx[2] = 150.0;
|
||||
let Wrote::Preview(pixels, (w, h), grew) = worker.put_text(doc, 0, &writing, 2.0).expect("previews") else {
|
||||
panic!("a preview");
|
||||
};
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(grew, "more lines than before");
|
||||
assert_eq!(pixels.len(), (w * h * 4) as usize);
|
||||
assert!(pixels.chunks_exact(4).any(|p| p[0] < 128), "it draws text");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), before);
|
||||
assert!(page_text(&mut worker, doc).contains("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_goes_where_asked() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let writing = Writing {
|
||||
block: None,
|
||||
expected: String::new(),
|
||||
text: "Added".into(),
|
||||
font: "courier".into(),
|
||||
size: 12.0,
|
||||
rgb: 0xff0000,
|
||||
bx: [100.0, 600.0, 300.0],
|
||||
};
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let added = worker.text_blocks(doc, 0).expect("blocks").into_iter().find(|b| b.text == "Added").expect("added");
|
||||
assert!((added.rect[0] - 100.0).abs() < 1.0 && (added.rect[1] - 600.0).abs() < 3.0, "{added:?}");
|
||||
assert_eq!(added.rgb, 0xff0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_move_scale_swap_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
worker.place(doc, 0, 1, [1.0, 0.0, 0.0, 1.0, 50.0, 20.0]).expect("moves");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
near(worker.objects(doc, 0).expect("objects")[1].rect, [122.0, 262.0, 222.0, 312.0]);
|
||||
// Twice the size about its top left.
|
||||
worker.place(doc, 0, 1, [2.0, 0.0, 0.0, 2.0, -122.0, -262.0]).expect("scales");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
near(worker.objects(doc, 0).expect("objects")[1].rect, [122.0, 262.0, 322.0, 362.0]);
|
||||
let square = dir.path().join("square.pnm");
|
||||
std::fs::write(&square, b"P6\n2 2\n255\n\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff").expect("writes");
|
||||
worker.replace_image(doc, 0, 1, &square).expect("replaces");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let swapped = worker.objects(doc, 0).expect("objects")[1].rect;
|
||||
near(swapped, [172.0, 262.0, 272.0, 362.0]);
|
||||
worker.delete_object(doc, 0, 0).expect("deletes the stamp");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(!page_text(&mut worker, doc).contains("STAMP"));
|
||||
assert_eq!(worker.objects(doc, 0).expect("objects").len(), 1);
|
||||
worker.add_image(doc, 0, &square, [400.0, 100.0, 450.0, 150.0]).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
near(worker.objects(doc, 0).expect("objects").last().expect("added").rect, [400.0, 100.0, 450.0, 150.0]);
|
||||
for _ in 0..5 {
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
near(worker.objects(doc, 0).expect("objects")[1].rect, [72.0, 242.0, 172.0, 292.0]);
|
||||
assert!(page_text(&mut worker, doc).contains("STAMP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_documents_own_font_writes_and_edits_edit_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let hello = |worker: &mut Worker, doc: Doc, start: &str| {
|
||||
worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with(start)).expect("the block")
|
||||
};
|
||||
let index = hello(&mut worker, doc, "Hello");
|
||||
let first = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "original");
|
||||
assert!(matches!(worker.put_text(doc, 0, &first, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let again = hello(&mut worker, doc, "Hi");
|
||||
assert!(worker.text_blocks(doc, 0).expect("blocks")[again].editable, "new text is the page's own");
|
||||
let second = rewrite(&mut worker, doc, again, "Yo world\nSecond line", "original");
|
||||
assert!(matches!(worker.put_text(doc, 0, &second, 0.0).expect("writes again"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let now = page_text(&mut worker, doc);
|
||||
assert!(now.contains("Yo world") && !now.contains("Hi world"), "{now}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_added_in_a_preview_or_undone_is_added_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let writing = rewrite(&mut worker, doc, index, "你好", "cjk-sc");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 2.0).expect("previews"), Wrote::Preview(..)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes again"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = worker.open(&out).expect("reopens").doc;
|
||||
assert!(page_text(&mut worker, saved).contains("你好"), "the font is in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_and_refusals_keep_the_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let first = rewrite(&mut worker, doc, index, "Hi world\nSecond line", "helvetica");
|
||||
assert!(matches!(worker.put_text(doc, 0, &first, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let preview = rewrite(&mut worker, doc, index, "Yo world\nSecond line", "times");
|
||||
assert!(matches!(worker.put_text(doc, 0, &preview, 2.0).expect("previews"), Wrote::Preview(..)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let refused = Writing { text: "你好".into(), font: "helvetica".into(), ..preview };
|
||||
assert!(matches!(worker.put_text(doc, 0, &refused, 0.0).expect("answers"), Wrote::Missing(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, true).expect("the edit still redoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(page_text(&mut worker, doc).contains("Hi world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bigger_glyphs_count_as_a_new_layout() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(dir.path());
|
||||
let index = worker.text_blocks(doc, 0).expect("blocks").iter().position(|b| b.text.starts_with("Hello")).expect("hello");
|
||||
let same = rewrite(&mut worker, doc, index, "Hello world\nSecond line", "original");
|
||||
let Wrote::Preview(_, _, grew) = worker.put_text(doc, 0, &same, 2.0).expect("previews") else { panic!("a preview") };
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(!grew, "the same text in the same place");
|
||||
let big = Writing { text: "Hi".into(), size: 72.0, ..same };
|
||||
let Wrote::Preview(_, _, grew) = worker.put_text(doc, 0, &big, 2.0).expect("previews") else { panic!("a preview") };
|
||||
assert!(grew, "fewer lines, much bigger glyphs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_on_one_baseline_still_wrap_onto_new_lines() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "BT /F1 18 Tf 1 0 0 1 72 720 Tm (Hello) Tj 1 0 0 1 160 720 Tm (world) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (n, obj) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", n + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let path = dir.path().join("runs.pdf");
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let block = worker.text_blocks(doc, 0).expect("blocks")[0].clone();
|
||||
let mut writing = rewrite(&mut worker, doc, 0, "One two three four five", "helvetica");
|
||||
writing.expected = block.text;
|
||||
writing.bx[2] = 50.0;
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mut tops: Vec<i32> = worker.text(doc, 0).expect("reads").iter().map(|c| c.rect[1].round() as i32).collect();
|
||||
tops.dedup();
|
||||
assert!(tops.len() >= 3, "each wrapped line has its own baseline: {tops:?}");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Page edits, undo and saving against the real worker (PDF-02).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Worker,
|
||||
sample::{Extras, pdf, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn open(worker: &mut Worker, dir: &Path, name: &str, bytes: &[u8]) -> Doc {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
worker.open(&path).expect("opens").doc
|
||||
}
|
||||
|
||||
/// Each page's text.
|
||||
fn texts(worker: &mut Worker, doc: Doc) -> Vec<String> {
|
||||
let count = worker.pages(doc).expect("counts");
|
||||
(0..count)
|
||||
.map(|page| worker.text(doc, page).expect("reads").iter().map(|c| c.c).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bookmark titles with their pages.
|
||||
fn marks(worker: &mut Worker, doc: Doc) -> Vec<(String, Option<usize>)> {
|
||||
worker.outline(doc).expect("the outline").into_iter().map(|m| (m.title, m.page)).collect()
|
||||
}
|
||||
|
||||
const LETTER: (f32, f32) = (612.0, 792.0);
|
||||
|
||||
fn three() -> Vec<u8> {
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])];
|
||||
let extras = Extras { links: &[(0, 1), (2, 1), (2, 0)], fields: &[1, 2], labels: &[(0, "r"), (1, "D")], ..Extras::default() };
|
||||
pdf_with(pages, &[("One", 0, &[]), ("Two", 1, &[]), ("Three", 2, &[])], &extras)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_drops_what_pointed_there_and_undoes() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let after = worker.delete_pages(doc, &[1]).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_ne!(before, after);
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "three"]);
|
||||
let pointing = marks(&mut worker, doc);
|
||||
assert_eq!(pointing, [("One".into(), Some(0)), ("Two".into(), None), ("Three".into(), Some(1))]);
|
||||
let out = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains("(three)") && !saved.contains("(two)"), "the deleted page is gone from the file");
|
||||
assert_eq!(saved.matches("/Link").count(), 1, "only the link to page one stays");
|
||||
assert!(saved.contains("field1") && !saved.contains("field0"), "the field on the deleted page goes");
|
||||
let labels = saved.split("/Nums").nth(1).expect("labels").replace(char::is_whitespace, "");
|
||||
assert!(labels.starts_with("[0<</S/r>>1<</S/D>>]"), "{labels}");
|
||||
assert!(!dir.path().join("saved.pdf.raw").exists(), "the scratch copy goes");
|
||||
assert_eq!(worker.undo(doc, false).expect("undoes"), before);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert_eq!(marks(&mut worker, doc)[1], ("Two".into(), Some(1)));
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), after);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_turns_and_crops_are_steps() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
worker.move_pages(doc, &[2], 0).expect("moves");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["three", "one", "two"]);
|
||||
assert_eq!(marks(&mut worker, doc)[2], ("Three".into(), Some(0)), "bookmarks follow their page");
|
||||
worker.move_pages(doc, &[0, 1], 3).expect("moves to the end");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["two", "three", "one"]);
|
||||
worker.rotate_pages(doc, &[1], 90).expect("turns");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]]);
|
||||
// Turned a quarter, the text's left edge is on top.
|
||||
let top = |worker: &mut Worker, doc: Doc| worker.text(doc, 1).expect("reads")[0].rect[1];
|
||||
let was = top(&mut worker, doc);
|
||||
worker.crop_pages(doc, &[1], [50.0, 0.0, 0.0, 0.0]).expect("crops");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 562.0]]);
|
||||
assert!((top(&mut worker, doc) - (was - 50.0)).abs() < 0.5, "the seen top was cut");
|
||||
worker.crop_pages(doc, &[1], [0.0; 4]).expect("uncrops");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]]);
|
||||
for _ in 0..5 {
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert_eq!(worker.bounds(doc, 0, 3).expect("bounds"), [[0.0, 0.0, LETTER.0, LETTER.1]; 3]);
|
||||
assert!(worker.undo(doc, false).is_err(), "nothing left to undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_come_in_blank_or_from_a_file() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
worker.insert_blank(doc, 1, (300.0, 400.0)).expect("inserts a blank page");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 1, 1).expect("bounds"), [[0.0, 0.0, 300.0, 400.0]]);
|
||||
let other = dir.path().join("other.pdf");
|
||||
std::fs::write(&other, pdf(&[(612.0, 792.0, &["x"]), (612.0, 792.0, &["y"])], &[])).expect("writes");
|
||||
let (count, _) = worker.insert_file(doc, 4, &other).expect("inserts a file");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "", "two", "three", "x", "y"]);
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extract_writes_some_pages_and_leaves_the_document() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
worker.rotate_pages(doc, &[0], 90).expect("turns");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
let out = dir.path().join("part.pdf");
|
||||
worker.extract(doc, &[0, 2], &out).expect("extracts");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark);
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert!(worker.undo(doc, true).is_err(), "the extract is not a redo");
|
||||
let part = worker.open(&out).expect("opens the part").doc;
|
||||
assert_eq!(texts(&mut worker, part), ["one", "three"]);
|
||||
assert_eq!(worker.bounds(part, 0, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]], "with its edits");
|
||||
worker.undo(doc, false).expect("the turn undoes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_edits_are_refused_and_change_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["big"]), (100.0, 100.0, &["small"])];
|
||||
let doc = open(&mut worker, dir.path(), "two.pdf", &pdf(pages, &[]));
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
assert!(worker.delete_pages(doc, &[0, 1]).is_err(), "a PDF keeps a page");
|
||||
assert!(worker.delete_pages(doc, &[1, 0]).is_err(), "pages in order");
|
||||
assert!(worker.rotate_pages(doc, &[0], 45).is_err(), "quarter turns only");
|
||||
// Fine for the big page, too much for the small.
|
||||
assert!(worker.crop_pages(doc, &[0, 1], [200.0, 0.0, 0.0, 0.0]).is_err());
|
||||
assert_eq!(worker.bounds(doc, 0, 2).expect("bounds"), [[0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 100.0, 100.0]], "rolled back");
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark);
|
||||
assert!(worker.undo(doc, false).is_err(), "no step was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extract_after_an_undo_keeps_the_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
let turned = worker.rotate_pages(doc, &[0], 90).expect("turns");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("saved.pdf")).expect("saves");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.extract(doc, &[0], &dir.path().join("part.pdf")).expect("extracts");
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), turned);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(texts(&mut worker, doc), ["one", "two", "three"]);
|
||||
assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 792.0, 612.0]], "the turn is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_pages_keep_their_links_and_fields() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let mut doc = open(&mut worker, dir.path(), "three.pdf", &three());
|
||||
let other = dir.path().join("other.pdf");
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"]), (612.0, 792.0, &["y"])];
|
||||
let extras = Extras { links: &[(0, 1)], fields: &[0], ..Extras::default() };
|
||||
std::fs::write(&other, pdf_with(pages, &[], &extras)).expect("writes");
|
||||
worker.insert_file(doc, 3, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let saved = |worker: &mut Worker, doc: Doc, name: &str| {
|
||||
let out = dir.path().join(name);
|
||||
worker.save(doc, &out).expect("saves");
|
||||
String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned()
|
||||
};
|
||||
let merged = saved(&mut worker, doc, "merged.pdf");
|
||||
assert_eq!(merged.matches("/Link").count(), 4, "three links of its own, one inserted");
|
||||
assert!(merged.contains("(field0 2)") && merged.contains("(value0)"), "a clashing field is renamed, its value kept");
|
||||
// The inserted link points at y: deleting y drops it.
|
||||
worker.delete_pages(doc, &[4]).expect("deletes y");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(saved(&mut worker, doc, "less.pdf").matches("/Link").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_without_its_page_named_still_goes_with_the_page() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"])];
|
||||
let extras = Extras { fields: &[1], loose: true, ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "loose.pdf", &pdf_with(pages, &[], &extras));
|
||||
worker.delete_pages(doc, &[1]).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(!saved.contains("value0"), "the field and its value are gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_margins_are_points_whatever_the_user_unit() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let extras = Extras { unit: Some(2.0), ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "big.pdf", &pdf_with(&[(612.0, 792.0, &["x"])], &[], &extras));
|
||||
assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 1224.0, 1584.0]]);
|
||||
worker.crop_pages(doc, &[0], [72.0, 0.0, 0.0, 0.0]).expect("crops");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.bounds(doc, 0, 1).expect("bounds"), [[0.0, 0.0, 1224.0, 1512.0]]);
|
||||
}
|
||||
|
||||
/// Runs `step` on its own thread, ten seconds at most.
|
||||
fn bounded<T: Send + 'static>(step: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (done, answer) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || done.send(step()).expect("the test waits"));
|
||||
answer.recv_timeout(std::time::Duration::from_secs(10)).expect("done in time")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_clashing_field_names_still_get_a_number() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let long = "a".repeat(255);
|
||||
let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])];
|
||||
let extras = Extras { fields: &[0], prefix: Some(&long), ..Extras::default() };
|
||||
let (mine, other) = (dir.path().join("mine.pdf"), dir.path().join("other.pdf"));
|
||||
std::fs::write(&mine, pdf_with(page, &[], &extras)).expect("writes");
|
||||
std::fs::write(&other, pdf_with(page, &[], &extras)).expect("writes");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let saved = out.clone();
|
||||
bounded(move || {
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&mine).expect("opens").doc;
|
||||
worker.insert_file(doc, 1, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
});
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains(&format!("({long}0 2)")), "the second is numbered");
|
||||
}
|
||||
|
||||
/// Red pixels of a page at one pixel a point.
|
||||
fn red(worker: &mut Worker, doc: Doc, page: usize) -> usize {
|
||||
let pixels = worker.render(doc, page, (1.0, 0), [0, 0, 612, 792], false).expect("renders").bgra;
|
||||
pixels.chunks_exact(4).filter(|p| p[2] > 180 && p[1] < 80 && p[0] < 80).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_fields_keep_the_look_of_their_form() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])];
|
||||
let other = dir.path().join("other.pdf");
|
||||
std::fs::write(&other, pdf_with(page, &[], &Extras { fields: &[0], look: Some(("Custom", "Custom")), ..Extras::default() })).expect("writes");
|
||||
let source = worker.open(&other).expect("opens").doc;
|
||||
let shown = red(&mut worker, source, 0);
|
||||
assert!(shown > 100, "the source draws its value red: {shown}");
|
||||
// Its own form gives the font name another font.
|
||||
let mine = Extras { fields: &[0], prefix: Some("mine"), look: Some(("Custom", "Custom")), ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "mine.pdf", &pdf_with(page, &[], &mine));
|
||||
worker.insert_file(doc, 1, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let merged = worker.open(&out).expect("opens").doc;
|
||||
assert_eq!(red(&mut worker, merged, 1), shown, "the same red, the same size");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains("(/Custom_2 24 Tf 1 0 0 rg)"), "the clashing font is renamed where it is used");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_font_name_and_the_form_alignment_come_along() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut worker = worker();
|
||||
let page: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["x"])];
|
||||
let other = dir.path().join("other.pdf");
|
||||
let theirs = Extras { fields: &[0], look: Some(("Custom", "C#75stom")), align: Some(2), ..Extras::default() };
|
||||
std::fs::write(&other, pdf_with(page, &[], &theirs)).expect("writes");
|
||||
let mine = Extras { fields: &[0], prefix: Some("mine"), look: Some(("Custom", "Custom")), align: Some(0), ..Extras::default() };
|
||||
let mut doc = open(&mut worker, dir.path(), "mine.pdf", &pdf_with(page, &[], &mine));
|
||||
worker.insert_file(doc, 1, &other).expect("inserts");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let saved = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
assert!(saved.contains("(/Custom_2 24 Tf 1 0 0 rg)"), "the escaped name is renamed too");
|
||||
assert!(saved.contains("/Q 2"), "right-aligned as in its own form");
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Ely's edits read back by another reader, PDFKit (QA-13).
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{FieldKind, NewField, Worker, Writing, Wrote, sample::{Extras, pdf_with}};
|
||||
use objc2::AllocAnyThread as _;
|
||||
use objc2_foundation::{NSString, NSURL};
|
||||
use objc2_pdf_kit::PDFDocument;
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Each page's text and widget values, as PDFKit reads them.
|
||||
fn read_back(path: &Path) -> Vec<(String, Vec<String>)> {
|
||||
let url = NSURL::fileURLWithPath(&NSString::from_str(path.to_str().expect("UTF-8")));
|
||||
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }.expect("PDFKit opens it");
|
||||
(0..unsafe { document.pageCount() })
|
||||
.map(|at| {
|
||||
let page = unsafe { document.pageAtIndex(at) }.expect("a page");
|
||||
let text = unsafe { page.string() }.map(|s| s.to_string()).unwrap_or_default();
|
||||
let values = unsafe { page.annotations() }.to_vec().into_iter().filter_map(|a| unsafe { a.widgetStringValue() }.map(|s| s.to_string())).collect();
|
||||
(text, values)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_a_field_and_page_order_read_back_elsewhere() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["First page"]), (612.0, 792.0, &["Second page"])], &[], &Extras::default())).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let block = worker.text_blocks(doc, 0).expect("blocks").into_iter().position(|b| b.text.contains("First page")).expect("the block");
|
||||
let writing = Writing {
|
||||
block: Some(block),
|
||||
expected: "First page".into(),
|
||||
text: "Changed words".into(),
|
||||
font: "helvetica".into(),
|
||||
size: 12.0,
|
||||
rgb: 0,
|
||||
bx: [72.0, 60.0, 400.0],
|
||||
};
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let field = NewField { kind: FieldKind::Text, name: "name".into(), rect: [72.0, 200.0, 272.0, 220.0], options: Vec::new() };
|
||||
worker.add_field(doc, 0, &field).expect("adds a field");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let id = worker.marks(doc, 0).expect("marks").fields[0].id;
|
||||
worker.fill(doc, 0, id, "Ada").expect("fills");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.move_pages(doc, &[1], 0).expect("moves");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("edited.pdf");
|
||||
worker.save(doc, &out).expect("saves");
|
||||
let pages = read_back(&out);
|
||||
assert_eq!(pages.len(), 2);
|
||||
assert!(pages[0].0.contains("Second page"), "{pages:?}");
|
||||
assert!(pages[1].0.contains("Changed words") && !pages[1].0.contains("First page"), "{pages:?}");
|
||||
assert_eq!(pages[1].1, vec!["Ada".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Annotations and form fields against the real worker (PDF-05, PDF-06).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, FieldKind, NewAnnot, NewField, Shape, Worker,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// A Letter page of two lines and a text field.
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let extras = Extras { fields: &[0], ..Extras::default() };
|
||||
std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn note(page: usize, shape: Shape) -> NewAnnot {
|
||||
NewAnnot { page, shape, rgb: None, contents: String::new(), author: "Ann".into() }
|
||||
}
|
||||
|
||||
/// Whether page one's pixels `at` hold colour `bgra`.
|
||||
fn has_color(worker: &mut Worker, doc: Doc, at: [i32; 4], bgra: [u8; 3]) -> bool {
|
||||
let pixels = worker.render(doc, 0, (1.0, 0), at, false).expect("draws").bgra;
|
||||
pixels.chunks_exact(4).any(|p| p[..3].iter().zip(bgra).all(|(a, b)| a.abs_diff(b) < 40))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_lands_in_one_step_and_reads_back() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let hello: Vec<_> = worker.text(doc, 0).expect("text").into_iter().take(5).collect();
|
||||
let line = [hello[0].rect[0], hello[0].rect[1], hello[4].rect[2], hello[4].rect[3]];
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let mut noted = note(0, Shape::Note((300.0, 300.0)));
|
||||
noted.contents = "Check this".into();
|
||||
let mut marked = note(0, Shape::Highlight(vec![line]));
|
||||
marked.rgb = Some(0x00ff00);
|
||||
let all = [
|
||||
marked,
|
||||
note(0, Shape::Underline(vec![line])),
|
||||
note(0, Shape::StrikeOut(vec![line])),
|
||||
noted,
|
||||
note(0, Shape::Square([100.0, 400.0, 200.0, 450.0])),
|
||||
note(0, Shape::Circle([250.0, 400.0, 350.0, 450.0])),
|
||||
note(0, Shape::Line([100.0, 500.0, 300.0, 520.0])),
|
||||
note(0, Shape::Ink(vec![vec![(100.0, 600.0), (150.0, 620.0), (200.0, 600.0)]])),
|
||||
note(0, Shape::Stamp([350.0, 550.0, 540.0, 600.0], "Approved".into())),
|
||||
];
|
||||
let after = worker.annotate(doc, &all).expect("annotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.marks(doc, 0).expect("marks").annots;
|
||||
let kinds: Vec<&str> = read.iter().map(|a| a.kind.as_str()).collect();
|
||||
assert_eq!(kinds, ["Highlight", "Underline", "StrikeOut", "Text", "Square", "Circle", "Line", "Ink", "Stamp"]);
|
||||
assert_eq!((read[0].rgb, read[3].contents.as_str(), read[3].author.as_str()), (Some(0x00ff00), "Check this", "Ann"));
|
||||
assert_eq!(read[8].icon, "Approved");
|
||||
assert!(has_color(&mut worker, doc, [line[0] as i32, line[1] as i32, 20, 10], [0, 255, 0]), "the highlight is drawn");
|
||||
assert_eq!(worker.undo(doc, false).expect("undoes"), before, "one step");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.marks(doc, 0).expect("marks").annots.is_empty());
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), after);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(other.marks(reopened, 0).expect("marks").annots.len(), 9, "kept in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_annotation_changes_nothing_and_keeps_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.annotate(doc, &[note(0, Shape::Square([100.0, 100.0, 200.0, 200.0]))]).expect("annotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let good = note(0, Shape::Circle([100.0, 100.0, 200.0, 200.0]));
|
||||
for bad in [
|
||||
note(0, Shape::Stamp([100.0, 100.0, 200.0, 200.0], "Hacked".into())),
|
||||
note(0, Shape::Highlight(Vec::new())),
|
||||
note(0, Shape::Ink(vec![vec![(1.0, 1.0)]])),
|
||||
note(3, Shape::Note((10.0, 10.0))),
|
||||
] {
|
||||
assert!(worker.annotate(doc, &[good.clone(), bad.clone()]).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(worker.marks(doc, 0).expect("marks").annots.is_empty(), "the good one did not land either");
|
||||
worker.undo(doc, true).expect("redo survives");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.marks(doc, 0).expect("marks").annots[0].kind, "Square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotations_change_and_go_by_their_number() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let two = [note(0, Shape::Note((50.0, 50.0))), note(0, Shape::Square([100.0, 100.0, 200.0, 200.0]))];
|
||||
worker.annotate(doc, &two).expect("annotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.marks(doc, 0).expect("marks").annots;
|
||||
worker.change_annot(doc, 0, read[0].id, Some(0x0000ff), Some("Done")).expect("changes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.delete_annot(doc, 0, read[1].id).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let now = worker.marks(doc, 0).expect("marks").annots;
|
||||
assert_eq!(now.len(), 1);
|
||||
assert_eq!((now[0].id, now[0].rgb, now[0].contents.as_str()), (read[0].id, Some(0x0000ff), "Done"));
|
||||
assert!(worker.delete_annot(doc, 0, read[1].id).is_err(), "gone already");
|
||||
worker.undo(doc, false).expect("undoes the delete");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.marks(doc, 0).expect("marks").annots[1].id, read[1].id, "the same object back");
|
||||
}
|
||||
|
||||
fn field(kind: FieldKind, name: &str, rect: [f32; 4]) -> NewField {
|
||||
let options = match kind {
|
||||
FieldKind::Choice => vec!["Red".into(), "Blue".into()],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
NewField { kind, name: name.into(), rect, options }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_fields_fill_and_keep_their_values() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
for new in [
|
||||
field(FieldKind::Text, "Name", [72.0, 300.0, 272.0, 320.0]),
|
||||
field(FieldKind::Checkbox, "Agree", [72.0, 340.0, 86.0, 354.0]),
|
||||
field(FieldKind::Radio, "Size", [72.0, 370.0, 86.0, 384.0]),
|
||||
field(FieldKind::Radio, "Size", [100.0, 370.0, 114.0, 384.0]),
|
||||
field(FieldKind::Choice, "Color", [72.0, 400.0, 200.0, 420.0]),
|
||||
] {
|
||||
worker.add_field(doc, 0, &new).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
assert!(!has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "an empty box");
|
||||
let fields = worker.marks(doc, 0).expect("marks").fields;
|
||||
let names: Vec<(&str, FieldKind)> = fields.iter().map(|f| (f.name.as_str(), f.kind)).collect();
|
||||
assert_eq!(names, [
|
||||
("field0", FieldKind::Text),
|
||||
("Name", FieldKind::Text),
|
||||
("Agree", FieldKind::Checkbox),
|
||||
("Size", FieldKind::Radio),
|
||||
("Size", FieldKind::Radio),
|
||||
("Color", FieldKind::Choice),
|
||||
]);
|
||||
assert_ne!(fields[3].on, fields[4].on, "each radio has its own state");
|
||||
assert_eq!(fields[5].options, ["Red", "Blue"]);
|
||||
worker.fill(doc, 0, fields[0].id, "changed").expect("fills the old field");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[1].id, "Zoe").expect("fills");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[2].id, &fields[2].on).expect("checks");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[3].id, &fields[3].on).expect("picks");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[4].id, &fields[4].on).expect("picks the other");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.fill(doc, 0, fields[5].id, "Blue").expect("chooses");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.fill(doc, 0, fields[3].id, "Nonsense").is_err(), "a state the button lacks");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
let read = other.marks(reopened, 0).expect("marks").fields;
|
||||
let values: Vec<(&str, bool)> = read.iter().map(|f| (f.value.as_str(), f.checked)).collect();
|
||||
assert_eq!(values[..2], [("changed", false), ("Zoe", false)]);
|
||||
assert!(read[2].checked && !read[3].checked && read[4].checked, "one radio of the group");
|
||||
assert_eq!(read[5].value, "Blue");
|
||||
assert!(has_color(&mut other, reopened, [76, 304, 190, 12], [0, 0, 0]), "the typed name is drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_names_stay_unique_and_groups_go_with_their_last_button() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "field0", [72.0, 300.0, 272.0, 320.0])).is_err(), "taken");
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Radio, "field0", [72.0, 300.0, 86.0, 314.0])).is_err(), "not a group");
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "a.b", [72.0, 300.0, 272.0, 320.0])).is_err(), "a dotted name");
|
||||
let mut empty = field(FieldKind::Choice, "Pick", [72.0, 300.0, 272.0, 320.0]);
|
||||
empty.options.clear();
|
||||
assert!(worker.add_field(doc, 0, &empty).is_err(), "a choice of nothing");
|
||||
worker.add_field(doc, 0, &field(FieldKind::Radio, "Size", [72.0, 370.0, 86.0, 384.0])).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let fields = worker.marks(doc, 0).expect("marks").fields;
|
||||
assert!(worker.change_field(doc, 0, fields[1].id, Some("field0"), None).is_err(), "renamed onto another");
|
||||
worker.change_field(doc, 0, fields[0].id, Some("Title"), None).expect("renames");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.change_field(doc, 0, fields[0].id, None, Some(&["x".into()])).is_err(), "text has no options");
|
||||
worker.delete_field(doc, 0, fields[1].id).expect("deletes the only button");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.add_field(doc, 0, &field(FieldKind::Text, "Size", [72.0, 300.0, 272.0, 320.0])).expect("the group is gone");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let names: Vec<String> = worker.marks(doc, 0).expect("marks").fields.into_iter().map(|f| f.name).collect();
|
||||
assert_eq!(names, ["Title", "Size"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_choice_takes_new_options_and_drops_a_lost_value() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.add_field(doc, 0, &field(FieldKind::Choice, "Color", [72.0, 400.0, 200.0, 420.0])).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let id = worker.marks(doc, 0).expect("marks").fields[1].id;
|
||||
worker.fill(doc, 0, id, "Red").expect("chooses");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.change_field(doc, 0, id, None, Some(&["Red".into(), "Green".into()])).expect("keeps red");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.marks(doc, 0).expect("marks").fields[1].value, "Red");
|
||||
worker.change_field(doc, 0, id, None, Some(&["Green".into()])).expect("drops red");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.marks(doc, 0).expect("marks").fields[1].clone();
|
||||
assert_eq!((read.value.as_str(), read.options), ("", vec!["Green".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_value_stays_drawn_through_undo_and_redo() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.add_field(doc, 0, &field(FieldKind::Text, "Name", [72.0, 300.0, 272.0, 320.0])).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let id = worker.marks(doc, 0).expect("marks").fields[1].id;
|
||||
worker.fill(doc, 0, id, "Zoe").expect("fills");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.undo(doc, true).expect("redoes before a draw");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "drawn");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(!has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "gone");
|
||||
worker.undo(doc, true).expect("redoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(has_color(&mut worker, doc, [76, 304, 190, 12], [0, 0, 0]), "drawn again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_field_renames_within_its_parent() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let extras = Extras { fields: &[0, 0], parent: Some("Person"), ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let fields = worker.marks(doc, 0).expect("marks").fields;
|
||||
let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["Person.field0", "Person.field1"]);
|
||||
assert!(worker.change_field(doc, 0, fields[0].id, Some("field1"), None).is_err(), "a sibling's name");
|
||||
worker.change_field(doc, 0, fields[0].id, Some("First"), None).expect("renames");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.add_field(doc, 0, &field(FieldKind::Text, "First", [72.0, 300.0, 272.0, 320.0])).expect("a top field of the same leaf");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let names: Vec<String> = worker.marks(doc, 0).expect("marks").fields.into_iter().map(|f| f.name).collect();
|
||||
assert_eq!(names, ["Person.First", "Person.field1", "First"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_name_is_listed_widget_or_not() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let extras = Extras { fields: &[0], bare: Some(("Text1", "Child")), ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
assert_eq!(worker.field_names(doc).expect("names"), ["field0", "Text1", "Text1.Child"]);
|
||||
assert!(worker.add_field(doc, 0, &field(FieldKind::Text, "Text1", [72.0, 300.0, 272.0, 320.0])).is_err(), "the bare parent holds it");
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! OCR and the hidden text layer, real worker (PDF-08).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, OcrLine, Worker, Writing, Wrote,
|
||||
sample::{pdf, scan},
|
||||
tessdata,
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Points a pixel when scanning.
|
||||
const SCALE: f32 = 200.0 / 72.0;
|
||||
|
||||
/// Page one of `doc`, saved as a gray scan.
|
||||
fn scan_of(worker: &mut Worker, doc: Doc, out: PathBuf) -> PathBuf {
|
||||
let (w, h) = ((612.0 * SCALE).ceil() as i32, (792.0 * SCALE).ceil() as i32);
|
||||
let bgra = worker.render(doc, 0, (SCALE, 0), [0, 0, w, h], false).expect("draws").bgra;
|
||||
let gray: Vec<u8> = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect();
|
||||
std::fs::write(&out, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes");
|
||||
out
|
||||
}
|
||||
|
||||
/// A scan of two English lines, opened.
|
||||
fn english(dir: &Path) -> (Worker, Doc) {
|
||||
let source = dir.join("text.pdf");
|
||||
std::fs::write(&source, pdf(&[(612.0, 792.0, &["Hello world", "Second line"])], &[])).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&source).expect("opens").doc;
|
||||
let path = scan_of(&mut worker, doc, dir.join("scan.pdf"));
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn words(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_reads_and_its_text_layer_finds_without_drawing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = english(dir.path());
|
||||
assert_eq!(words(&mut worker, doc), "", "a scan has no text");
|
||||
let lines = worker.ocr_page(doc, 0, "eng", &tessdata().expect("data"), 300.0).expect("reads");
|
||||
let read: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect();
|
||||
assert!(read.iter().any(|l| l.contains("Hello world")) && read.iter().any(|l| l.contains("Second line")), "{read:?}");
|
||||
let hello = lines.iter().find(|l| l.text.contains("Hello")).expect("hello");
|
||||
assert!((hello.rect[0] - 72.0).abs() < 6.0 && (hello.rect[3] - 76.0).abs() < 8.0, "in page points: {:?}", hello.rect);
|
||||
let before = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra;
|
||||
let mark = worker.text_layer(doc, &[(0, lines)], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(words(&mut worker, doc).contains("Hello world"), "searchable now");
|
||||
assert_eq!(worker.search(doc, 0, "second line").expect("finds").len(), 1);
|
||||
assert!(worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra == before, "nothing drawn");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(words(&mut worker, doc), "", "one step");
|
||||
assert_eq!(worker.undo(doc, true).expect("redoes"), mark);
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert!(words(&mut other, reopened).contains("Hello world"), "kept in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_layer_replaces_the_last_or_adds_beside_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = english(dir.path());
|
||||
let line = OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "Hello world".into() };
|
||||
worker.text_layer(doc, &[(0, vec![line.clone()])], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.text_layer(doc, &[(0, vec![line.clone()])], false).expect("lays again");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.search(doc, 0, "hello").expect("finds").len(), 2, "two layers");
|
||||
worker.text_layer(doc, &[(0, vec![line])], true).expect("replaces");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.search(doc, 0, "hello").expect("finds").len(), 1, "old layers gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_layer_changes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = english(dir.path());
|
||||
let before = worker.history(doc).expect("a mark");
|
||||
let good = (0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "fine".into() }]);
|
||||
for bad in [
|
||||
(0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "𠀀".into() }]),
|
||||
(0, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: String::new() }]),
|
||||
(5, vec![OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "x".into() }]),
|
||||
] {
|
||||
assert!(worker.text_layer(doc, &[good.clone(), bad.clone()], true).is_err(), "{bad:?}");
|
||||
}
|
||||
assert_eq!((worker.history(doc).expect("a mark"), words(&mut worker, doc)), (before, String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_reads_in_both_scripts() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = dir.path().join("blank.pdf");
|
||||
std::fs::write(&source, pdf(&[(612.0, 792.0, &[])], &[])).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&source).expect("opens").doc;
|
||||
for (font, text, at) in [("cjk-sc", "简体中文识别", 100.0), ("cjk-tc", "繁體中文識別", 300.0)] {
|
||||
let writing = Writing { block: None, expected: String::new(), text: text.into(), font: font.into(), size: 36.0, rgb: 0, bx: [72.0, at, 400.0] };
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
}
|
||||
let path = scan_of(&mut worker, doc, dir.path().join("scan.pdf"));
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
let lines = worker.ocr_page(doc, 0, "chi_sim+chi_tra+eng", &tessdata().expect("data"), 300.0).expect("reads");
|
||||
let read: String = lines.iter().map(|l| l.text.replace(' ', "")).collect();
|
||||
assert!(read.contains("简体中文") && read.contains("繁體中文"), "{read}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_language_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = english(dir.path());
|
||||
let error = worker.ocr_page(doc, 0, "xyz", &tessdata().expect("data"), 300.0).expect_err("no such data");
|
||||
assert!(error.to_string().contains("Tesseract"), "{error}");
|
||||
assert!(worker.ocr_page(doc, 0, "eng", &tessdata().expect("data"), 300.0).is_ok(), "the worker goes on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_language_that_does_not_load_fails_the_read() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = english(dir.path());
|
||||
let data = dir.path().join("tessdata");
|
||||
std::fs::create_dir(&data).expect("a data dir");
|
||||
std::fs::copy(tessdata().expect("data").join("eng.traineddata"), data.join("eng.traineddata")).expect("copies");
|
||||
let error = worker.ocr_page(doc, 0, "eng+chi_sim", &data, 300.0).expect_err("chi_sim is missing");
|
||||
assert!(error.to_string().contains("1 of 2 languages"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_added_after_a_layer_still_shows() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = english(dir.path());
|
||||
let line = OcrLine { rect: [72.0, 60.0, 200.0, 78.0], text: "Hello world".into() };
|
||||
worker.text_layer(doc, &[(0, vec![line])], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let before = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra;
|
||||
let writing = Writing { block: None, expected: String::new(), text: "VISIBLE".into(), font: "helvetica".into(), size: 24.0, rgb: 0, bx: [72.0, 600.0, 300.0] };
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws").bgra != before, "the new text is drawn");
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Images, text, optimized copies, printing leave, repaired files (C11.8).
|
||||
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Lock, Optimizing, Worker,
|
||||
proto::may,
|
||||
sample::{Extras, one_page, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn two_pages(dir: &Path) -> std::path::PathBuf {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["First page"]), (612.0, 792.0, &["Second page"])], &[], &Extras::default())).expect("writes");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_exports_as_a_png_of_its_size() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let png = dir.path().join("page.png");
|
||||
worker.export_image(doc, 1, 144.0, &png).expect("exports");
|
||||
let bytes = std::fs::read(&png).expect("reads");
|
||||
assert_eq!(&bytes[1..4], b"PNG");
|
||||
let size = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().expect("four bytes"));
|
||||
assert_eq!((size(16), size(20)), (1224, 1584), "twice the points");
|
||||
assert!(worker.export_image(doc, 0, 5000.0, &png).is_err(), "an absurd resolution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_exports_page_by_page() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let txt = dir.path().join("doc.txt");
|
||||
worker.export_text(doc, &txt).expect("exports");
|
||||
let text = std::fs::read_to_string(&txt).expect("UTF-8");
|
||||
let pages: Vec<&str> = text.split('\u{c}').collect();
|
||||
assert_eq!(pages.len(), 2);
|
||||
assert!(pages[0].contains("First page") && pages[1].contains("Second page"), "{text:?}");
|
||||
}
|
||||
|
||||
/// A 600-pixel picture drawn 100 points wide: 432 dpi.
|
||||
fn heavy(dir: &Path) -> std::path::PathBuf {
|
||||
let pixels = vec![120u8; 600 * 600 * 3];
|
||||
let mut picture = format!("<< /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n", pixels.len()).into_bytes();
|
||||
picture.extend_from_slice(&pixels);
|
||||
picture.extend_from_slice(b"\nendstream");
|
||||
let path = dir.join("heavy.pdf");
|
||||
let mut bytes = one_page("", " /Resources << /XObject << /Im 5 0 R >> >>", "q 100 0 0 100 72 600 cm /Im Do Q", &["PICTURE".to_string()]);
|
||||
let at = bytes.windows(7).position(|w| w == b"PICTURE").expect("the slot");
|
||||
bytes.splice(at..at + 7, picture);
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optimized_copy_is_smaller_and_its_picture_downsampled() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = heavy(dir.path());
|
||||
let (mut worker, doc) = opened(&source);
|
||||
let out = dir.path().join("small.pdf");
|
||||
worker.optimize(doc, &out, Optimizing { compress: true, from: 300, to: 150 }).expect("optimizes");
|
||||
let (before, after) = (std::fs::metadata(&source).expect("size").len(), std::fs::metadata(&out).expect("size").len());
|
||||
assert!(after * 10 < before, "{before} -> {after}");
|
||||
let mut other = self::worker();
|
||||
let read = other.open(&out).expect("opens").doc;
|
||||
assert_eq!(other.pages(read).expect("pages"), 1);
|
||||
let text = String::from_utf8_lossy(&std::fs::read(&out).expect("reads")).into_owned();
|
||||
let width: u32 = text.split("/Width ").nth(1).and_then(|rest| rest.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).expect("a width");
|
||||
assert!((205..=212).contains(&width), "150 dpi over 100 points, not {width}");
|
||||
assert!(worker.optimize(doc, &out, Optimizing { compress: true, from: 150, to: 300 }).is_err(), "up is not down");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_and_copying_need_leave() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let limited = dir.path().join("limited.pdf");
|
||||
worker.protect(doc, &limited, Some(&Lock { user: String::new(), owner: "owner".into(), allow: may::EDIT })).expect("protects");
|
||||
let (mut other, read) = opened(&limited);
|
||||
for refused in [other.print_copy(read, &dir.path().join("p.pdf")), other.export_text(read, &dir.path().join("t.txt"))] {
|
||||
assert_eq!(refused.expect_err("refused").kind(), ErrorKind::PermissionDenied);
|
||||
}
|
||||
worker.print_copy(doc, &dir.path().join("print.pdf")).expect("an unprotected file prints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_opens_as_a_repaired_copy_and_stays_as_it_was() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = two_pages(dir.path());
|
||||
let mut bytes = std::fs::read(&path).expect("reads");
|
||||
// Breaks every cross-reference offset.
|
||||
let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer");
|
||||
bytes.truncate(at);
|
||||
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, mut doc) = opened(&path);
|
||||
let report = worker.repair_report(doc).expect("asks").expect("repaired");
|
||||
assert!(!report.trim().is_empty(), "MuPDF says what it found");
|
||||
assert_eq!(worker.pages(doc).expect("pages"), 2);
|
||||
worker.rotate_pages(doc, &[0], 90).expect("edits the copy");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "the damaged original stays");
|
||||
let elsewhere = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut fresh, clean) = opened(&two_pages(elsewhere.path()));
|
||||
assert_eq!(fresh.repair_report(clean).expect("asks"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_object_with_a_sound_trailer_is_repaired_on_open() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = two_pages(dir.path());
|
||||
let mut bytes = std::fs::read(&path).expect("reads");
|
||||
// The page tree's offset points nowhere; startxref is sound.
|
||||
let table = bytes.windows(6).position(|w| w == b"\nxref\n").expect("the table") + 6;
|
||||
let entries = table + bytes[table..].iter().position(|b| *b == b'\n').expect("a header") + 1;
|
||||
let at = entries + 2 * 20;
|
||||
bytes[at..at + 10].copy_from_slice(b"0000000009");
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, mut doc) = opened(&path);
|
||||
assert!(worker.repair_report(doc).expect("asks").is_some(), "found at open, not mid-edit");
|
||||
worker.rotate_pages(doc, &[0], 90).expect("edits");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_stream_under_a_sound_table_is_damage_and_fails_exports() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let broken = "<< /Length 12 /Filter /FlateDecode >>\nstream\nnot a zlib!!\nendstream".to_string();
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let bytes = one_page("", " /Contents 5 0 R", "", &[broken]);
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, mut doc) = opened(&path);
|
||||
let report = worker.repair_report(doc).expect("asks").expect("damage is reported");
|
||||
assert!(!report.trim().is_empty());
|
||||
assert!(worker.export_text(doc, &dir.path().join("doc.txt")).is_err(), "partial text is no export");
|
||||
worker.rotate_pages(doc, &[0], 90).expect("edits the copy");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.save(doc, &dir.path().join("fixed.pdf")).expect("saves the copy");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_copy_carries_no_lock() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&two_pages(dir.path()));
|
||||
let locked = dir.path().join("locked.pdf");
|
||||
worker.protect(doc, &locked, Some(&Lock { user: "user".into(), owner: "owner".into(), allow: may::PRINT })).expect("protects");
|
||||
let (mut other, read) = opened(&locked);
|
||||
assert!(other.unlock(read, "user").expect("tries"));
|
||||
let print = dir.path().join("print.pdf");
|
||||
other.print_copy(read, &print).expect("writes");
|
||||
let mut third = self::worker();
|
||||
let opened = third.open(&print).expect("opens");
|
||||
assert!(!opened.locked, "the printer needs no password");
|
||||
assert_eq!(third.pages(opened.doc).expect("pages"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_picture_shows_when_drawn_or_checked() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let picture = "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length 12 >>\nstream\nnot a zlib!!\nendstream".to_string();
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let bytes = one_page("", " /Resources << /XObject << /Im 5 0 R >> >>", "q 100 0 0 100 72 600 cm /Im Do Q", &[picture]);
|
||||
std::fs::write(&path, &bytes).expect("writes");
|
||||
let (mut worker, doc) = opened(&path);
|
||||
assert_eq!(worker.repair_report(doc).expect("asks"), None, "pictures wait to be drawn");
|
||||
let tile = worker.render(doc, 0, (1.0, 0), [0, 0, 612, 792], false).expect("draws");
|
||||
assert!(tile.damaged, "drawing finds it");
|
||||
assert!(worker.repair_report(doc).expect("asks").is_some());
|
||||
let (mut other, again) = opened(&path);
|
||||
assert!(other.check_pictures(again).expect("checks"), "the check finds it undrawn");
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Worker records reach the app log; document text never.
|
||||
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_pdf_engine::{LinkChange, Target, Worker, sample::pdf};
|
||||
|
||||
/// Every record the app would log, kept for the test.
|
||||
struct Kept;
|
||||
|
||||
fn kept() -> &'static Mutex<Vec<String>> {
|
||||
static KEPT: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||
KEPT.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
impl log::Log for Kept {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
kept().lock().unwrap().push(format!("{} {} {}", record.level(), record.target(), record.args()));
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_stream_keeps_its_text_out_of_the_log() {
|
||||
log::set_boxed_logger(Box::new(Kept)).unwrap();
|
||||
log::set_max_level(log::LevelFilter::Trace);
|
||||
// The worker logs at debug; the relay filters.
|
||||
unsafe { std::env::set_var("ELY_LOG", "debug") };
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("broken.pdf");
|
||||
// An unknown operator: MuPDF warns, quoting it.
|
||||
let line = "ZqxPrivate42) Tj ZqxPrivate42 (tail";
|
||||
std::fs::write(&path, pdf(&[(612.0, 792.0, &[line])], &[])).unwrap();
|
||||
let mut worker = Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).unwrap();
|
||||
let opened = worker.open(&path).unwrap();
|
||||
let _ = worker.text(opened.doc, 0);
|
||||
// A refused address holds a credential, unquoted.
|
||||
let secret = Target::Web("ftp://ana:ZqxPrivate42@files.example/document".into());
|
||||
let at = worker.now(opened.doc).unwrap();
|
||||
assert!(worker.change_link(at, 0, &LinkChange::Add { rect: [72.0, 300.0, 200.0, 320.0], to: secret }).is_err());
|
||||
worker.close(opened.doc).unwrap();
|
||||
drop(worker);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let records = kept().lock().unwrap().clone();
|
||||
assert!(records.iter().any(|r| r.contains("native output withheld")), "MuPDF spoke: {records:#?}");
|
||||
assert!(records.iter().any(|r| r.contains("ely_pdf_worker") || r.contains("pdf-worker")), "the worker's own records pass");
|
||||
let leaked: Vec<_> = records.iter().filter(|r| r.contains("ZqxPrivate42")).collect();
|
||||
assert!(leaked.is_empty(), "document text reached the log: {leaked:#?}");
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Removal and cleaning, checked in the file (QA-14).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Cleaning, Doc, NewAnnot, OcrLine, Region, Shape, Worker,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Two lines, a picture at 72..172 across, 242..292 down.
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let extras = Extras { picture: true, ..Extras::default() };
|
||||
std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn words(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
/// The box of the first `n` characters.
|
||||
fn first(worker: &mut Worker, doc: Doc, n: usize) -> [f32; 4] {
|
||||
let chars = worker.text(doc, 0).expect("text");
|
||||
let (a, b) = (chars[0].rect, chars[n - 1].rect);
|
||||
[a[0] - 1.0, a[1] - 1.0, b[2] + 0.5, b[3] + 1.0]
|
||||
}
|
||||
|
||||
fn reopen(path: &Path) -> (Worker, Doc) {
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_text_is_gone_from_the_file_and_the_rest_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let hello = first(&mut worker, doc, 5);
|
||||
let line = OcrLine { rect: hello, text: "Hidden".into() };
|
||||
worker.text_layer(doc, &[(0, vec![line])], false).expect("an invisible layer over it");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let report = worker.redact(doc, &[Region { page: 0, rect: hello }], &out).expect("removes");
|
||||
assert_eq!((report.text, report.light, report.versions), (0, 0, 1));
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark, "the open document is untouched");
|
||||
assert!(words(&mut worker, doc).contains("Hello"));
|
||||
let (mut other, written) = reopen(&out);
|
||||
let left = words(&mut other, written);
|
||||
assert!(!left.contains("Hello") && !left.contains("Hidden"), "{left}");
|
||||
assert!(left.contains("world") && left.contains("Second line"), "{left}");
|
||||
let bytes = std::fs::read(&out).expect("reads");
|
||||
assert!(!bytes.windows(5).any(|w| w == b"Hello"), "no trace in the bytes");
|
||||
assert_eq!(bytes.windows(9).filter(|w| w == b"startxref").count(), 1, "one revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_under_a_region_loses_those_pixels() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [72.0, 242.0, 122.0, 292.0] }], &out).expect("removes");
|
||||
let (mut other, written) = reopen(&out);
|
||||
let left = other.render(written, 0, (1.0, 0), [124, 244, 46, 46], false).expect("draws").bgra;
|
||||
assert!(left.chunks_exact(4).any(|p| p[0] > 100 || p[1] > 100 || p[2] > 100), "the right half keeps its colour");
|
||||
let gone = other.render(written, 0, (1.0, 0), [74, 244, 46, 46], false).expect("draws").bgra;
|
||||
assert!(gone.chunks_exact(4).all(|p| p[0] < 40 && p[1] < 40 && p[2] < 40), "the left half is black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_in_a_region_go_and_others_stay() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let note = |at| NewAnnot { page: 0, shape: Shape::Note(at), rgb: None, contents: "secret".into(), author: String::new() };
|
||||
worker.annotate(doc, &[note((80.0, 400.0)), note((400.0, 600.0))]).expect("notes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [70.0, 390.0, 120.0, 430.0] }], &out).expect("removes");
|
||||
let (mut other, written) = reopen(&out);
|
||||
let kept = other.marks(written, 0).expect("marks").annots;
|
||||
assert_eq!(kept.len(), 1, "the far note stays");
|
||||
assert!(kept[0].rect[0] > 300.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_regions_write_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let out = dir.path().join("out.pdf");
|
||||
assert!(worker.redact(doc, &[], &out).is_err());
|
||||
assert!(worker.redact(doc, &[Region { page: 4, rect: [0.0, 0.0, 10.0, 10.0] }], &out).is_err());
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleaning_reaches_what_it_counted_and_leaves_none() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.set_properties(doc, &[("Title", "Secret plan"), ("Author", "Zoe")]).expect("sets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let note = NewAnnot { page: 0, shape: Shape::Note((300.0, 300.0)), rgb: None, contents: "x".into(), author: String::new() };
|
||||
worker.annotate(doc, &[note]).expect("notes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "attached").expect("writes");
|
||||
worker.attach(doc, "a.txt", &file).expect("attaches");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.text_layer(doc, &[(0, vec![OcrLine { rect: [72.0, 600.0, 200.0, 620.0], text: "invisible".into() }])], false).expect("lays");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert!(reach.meta >= 2 && reach.annots == 1 && reach.files == 1 && reach.hidden == 1, "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let all = Cleaning { meta: true, annots: true, files: true, hidden: true };
|
||||
let left = worker.clean(doc, all, &out).expect("cleans");
|
||||
assert_eq!((left.meta, left.annots, left.files, left.hidden, left.scripts), (0, 0, 0, 0, 0));
|
||||
let (mut other, written) = reopen(&out);
|
||||
assert_eq!(other.properties(written).expect("reads").title, None);
|
||||
assert!(other.attachments(written).expect("lists").is_empty());
|
||||
assert!(!words(&mut other, written).contains("invisible"));
|
||||
assert!(words(&mut other, written).contains("Hello world"), "seen text stays");
|
||||
assert_eq!(worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans"), reach, "the open document is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleaning_one_kind_leaves_the_others() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
worker.set_properties(doc, &[("Title", "Plan")]).expect("sets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let note = NewAnnot { page: 0, shape: Shape::Note((300.0, 300.0)), rgb: None, contents: "x".into(), author: String::new() };
|
||||
worker.annotate(doc, &[note]).expect("notes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let left = worker.clean(doc, Cleaning { annots: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert_eq!(left.annots, 0);
|
||||
assert!(left.meta >= 1, "properties were not asked for");
|
||||
assert!(worker.clean(doc, Cleaning::default(), &dir.path().join("none.pdf")).is_err(), "nothing chosen");
|
||||
}
|
||||
|
||||
/// Helvetica as object 5, for the hand-made pages.
|
||||
fn helvetica() -> String {
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()
|
||||
}
|
||||
|
||||
fn written(dir: &Path, bytes: Vec<u8>) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
reopen(&path)
|
||||
}
|
||||
|
||||
fn holds(path: &Path, needle: &str) -> bool {
|
||||
std::fs::read(path).expect("reads").windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_touching_a_region_goes_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", "", "1 0 0 rg 100 700 200 40 re f", &[]));
|
||||
let out = dir.path().join("out.pdf");
|
||||
let report = worker.redact(doc, &[Region { page: 0, rect: [90.0, 45.0, 180.0, 100.0] }], &out).expect("removes");
|
||||
assert_eq!(report.objects, 0);
|
||||
let (mut other, read) = reopen(&out);
|
||||
let right = other.render(read, 0, (1.0, 0), [200, 55, 90, 30], false).expect("draws").bgra;
|
||||
assert!(right.chunks_exact(4).all(|p| p[2] < 200 || p[1] > 200), "no red left past the box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_in_a_region_takes_its_parent_and_value() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let objects = [
|
||||
"<< /FT /Tx /T (parent) /V (SECRET-FIELD-VALUE) /Kids [6 0 R] >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [70 700 220 740] /P 3 0 R >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AcroForm << /Fields [5 0 R] >>", " /Annots [6 0 R]", "", &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [60.0, 40.0, 230.0, 100.0] }], &out).expect("removes");
|
||||
assert!(!holds(&out, "SECRET-FIELD-VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_layers_faint_text_and_hidden_notes_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "BT /F1 12 Tf 72 700 Td (VISIBLE) Tj ET \
|
||||
/OC /H BDC BT /F1 12 Tf 72 680 Td (SECRET-OFF-LAYER) Tj ET EMC \
|
||||
q /G0 gs BT /F1 12 Tf 72 660 Td (SECRET-FAINT) Tj ET Q";
|
||||
let objects = [
|
||||
helvetica(),
|
||||
"<< /Type /OCG /Name (Hidden) >>".to_string(),
|
||||
"<< /Type /ExtGState /ca 0 >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Text /F 2 /Rect [300 300 320 320] /Contents (SECRET-HIDDEN-NOTE) >>".to_string(),
|
||||
];
|
||||
let catalog = " /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >>";
|
||||
let page = " /Resources << /Font << /F1 5 0 R >> /Properties << /H 6 0 R >> /ExtGState << /G0 7 0 R >> >> /Annots [8 0 R]";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(catalog, page, content, &objects));
|
||||
let scratch = dir.path().join("scan.pdf");
|
||||
let reach = worker.clean_scan(doc, &scratch).expect("scans");
|
||||
assert_eq!((reach.hidden, reach.hidden_annots, reach.annots), (1, 1, 0), "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans without /Names");
|
||||
for secret in ["SECRET-OFF-LAYER", "SECRET-FAINT", "SECRET-HIDDEN-NOTE"] {
|
||||
assert!(!holds(&out, secret), "{secret}");
|
||||
}
|
||||
assert!(holds(&out, "VISIBLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_on_pages_cleans_out_too() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let xmp = "<x:xmpmeta>SECRET-PAGE-METADATA</x:xmpmeta>";
|
||||
let objects = [format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", " /Metadata 5 0 R", "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!(reach.meta, 1);
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { meta: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, "SECRET-PAGE-METADATA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_content_says_nothing_after_removal() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "/Span << /MCID 0 /ActualText (SECRET-ACTUAL) >> BDC BT /F1 12 Tf 72 700 Td (Hello) Tj ET EMC";
|
||||
let objects = [
|
||||
helvetica(),
|
||||
"<< /Type /StructTreeRoot /K [7 0 R] >>".to_string(),
|
||||
"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /Alt (SECRET-STRUCT) /E (SECRET-EXPANDED) >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /StructTreeRoot 6 0 R", " /Resources << /Font << /F1 5 0 R >> >>", content, &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [60.0, 80.0, 200.0, 100.0] }], &out).expect("removes");
|
||||
for secret in ["SECRET-ACTUAL", "SECRET-STRUCT", "SECRET-EXPANDED", "Hello"] {
|
||||
assert!(!holds(&out, secret), "{secret}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_regions_both_remove() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let hello = first(&mut worker, doc, 5);
|
||||
let after = [hello[2] - 2.0, hello[1], hello[2] + 60.0, hello[3]];
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: hello }, Region { page: 0, rect: after }], &out).expect("removes");
|
||||
let (mut other, read) = reopen(&out);
|
||||
assert!(!words(&mut other, read).contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_metadata_and_associated_files_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let secret = "SECRET-AF-BYTES";
|
||||
let objects = [
|
||||
"<< /Type /Filespec /F (a.txt) /EF << /F 6 0 R >> >>".to_string(),
|
||||
format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{secret}\nendstream", secret.len()),
|
||||
];
|
||||
let page = " /Extra << /Deep << /PieceInfo << /App << /Private (SECRET-PIECE) >> >> >> >>";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AF [5 0 R]", page, "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!((reach.meta, reach.files), (1, 1), "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { meta: true, files: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, secret) && !holds(&out, "SECRET-PIECE") && !holds(&out, "/AF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_on_links_fields_and_chains_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let objects = [
|
||||
"<< /Type /Annot /Subtype /Link /Rect [72 600 200 620] /A << /S /JavaScript /JS (SECRET-LINK-JS) >> >>".to_string(),
|
||||
"<< /FT /Tx /T (p) /AA << /K << /S /JavaScript /JS (SECRET-FIELD-JS) >> >> /Kids [7 0 R] >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [72 500 200 520] /P 3 0 R >>".to_string(),
|
||||
];
|
||||
let catalog = " /AcroForm << /Fields [6 0 R] >> /OpenAction << /S /GoTo /D [3 0 R /Fit] \
|
||||
/Next << /S /JavaScript /JS (SECRET-NEXT-JS) /Next << /S /Named /N /LastPage >> >> >>";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(catalog, " /Annots [5 0 R 7 0 R]", "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!(reach.scripts, 3, "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
for secret in ["SECRET-LINK-JS", "SECRET-FIELD-JS", "SECRET-NEXT-JS"] {
|
||||
assert!(!holds(&out, secret), "{secret}");
|
||||
}
|
||||
assert!(holds(&out, "/LastPage") && holds(&out, "/GoTo"), "what follows a script stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_placed_by_their_kids_say_nothing_after_removal() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "/Span << /MCID 0 >> BDC BT /F1 12 Tf 72 700 Td (Hello) Tj ET EMC";
|
||||
let objects = [
|
||||
helvetica(),
|
||||
"<< /Type /StructTreeRoot /K [7 0 R] >>".to_string(),
|
||||
"<< /Type /StructElem /S /P /P 6 0 R /K << /Type /MCR /Pg 3 0 R /MCID 0 >> /ActualText (SECRET-MCR) >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /StructTreeRoot 6 0 R", " /Resources << /Font << /F1 5 0 R >> >>", content, &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [60.0, 80.0, 200.0, 100.0] }], &out).expect("removes");
|
||||
assert!(!holds(&out, "SECRET-MCR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redacted_page_loses_its_thumbnail() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let pixels = "SECRET-THUMB";
|
||||
let objects = [format!("<< /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n{pixels}\nendstream", pixels.len())];
|
||||
let page = " /Resources << /XObject << /Im 5 0 R >> >> /Thumb 5 0 R";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", page, "q 100 0 0 50 0 742 cm /Im Do Q", &objects));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.redact(doc, &[Region { page: 0, rect: [0.0, 0.0, 100.0, 50.0] }], &out).expect("removes");
|
||||
assert!(!holds(&out, pixels) && !holds(&out, "/Thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_takes_what_its_marks_say() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "/Span << /ActualText (SECRET-HIDDEN-ACTUAL) >> BDC BT /F1 12 Tf 3 Tr 72 700 Td (Unseen) Tj ET EMC";
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page("", " /Resources << /Font << /F1 5 0 R >> >>", content, &[helvetica()]));
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, "SECRET-HIDDEN-ACTUAL") && !holds(&out, "Unseen"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_links_and_fields_clean_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let objects = [
|
||||
"<< /Type /Annot /Subtype /Link /F 2 /Rect [72 600 200 620] /Contents (SECRET-HIDDEN-LINK) /A << /S /URI /URI (https://x.test/SECRET-HIDDEN-LINK) >> >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Widget /F 2 /FT /Tx /T (f) /V (SECRET-HIDDEN-WIDGET) /Rect [72 500 200 520] /P 3 0 R >>".to_string(),
|
||||
];
|
||||
let (mut worker, doc) = written(dir.path(), ely_pdf_engine::sample::one_page(" /AcroForm << /Fields [6 0 R] >>", " /Annots [5 0 R 6 0 R]", "", &objects));
|
||||
let reach = worker.clean_scan(doc, &dir.path().join("scan.pdf")).expect("scans");
|
||||
assert_eq!(reach.hidden_annots, 2, "{reach:?}");
|
||||
let out = dir.path().join("out.pdf");
|
||||
worker.clean(doc, Cleaning { hidden: true, ..Cleaning::default() }, &out).expect("cleans");
|
||||
assert!(!holds(&out, "SECRET-HIDDEN-LINK") && !holds(&out, "SECRET-HIDDEN-WIDGET"));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Protected copies and what they allow (PDF-11).
|
||||
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Lock, Worker,
|
||||
proto::may,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn plain(dir: &Path) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
/// Writes a copy that opens with "user", allowing only `allow`.
|
||||
fn locked(dir: &Path, allow: u32) -> std::path::PathBuf {
|
||||
let (mut worker, doc) = plain(dir);
|
||||
let out = dir.join("locked.pdf");
|
||||
let lock = Lock { user: "user".into(), owner: "owner".into(), allow };
|
||||
worker.protect(doc, &out, Some(&lock)).expect("protects");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protected_copy_asks_and_holds_its_limits() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let out = locked(dir.path(), may::PRINT);
|
||||
let bytes = std::fs::read(&out).expect("reads");
|
||||
assert!(bytes.windows(5).any(|w| w == b"AESV3"), "AES-256");
|
||||
let mut worker = worker();
|
||||
let opened = worker.open(&out).expect("opens");
|
||||
assert!(opened.locked);
|
||||
assert!(!worker.unlock(opened.doc, "wrong").expect("tries"));
|
||||
assert!(worker.unlock(opened.doc, "user").expect("tries"));
|
||||
let allowed = worker.permissions(opened.doc).expect("reads");
|
||||
assert_eq!(allowed & (may::PRINT | may::COPY | may::EDIT | may::OWNER), may::PRINT);
|
||||
let refused = worker.rotate_pages(opened.doc, &[0], 90).expect_err("refused");
|
||||
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "assemble".to_string()));
|
||||
let text: String = worker.text(opened.doc, 0).expect("still reads").iter().map(|c| c.c).collect();
|
||||
assert!(text.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_owner_password_keeps_access_and_the_right_one_lifts_limits() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let out = locked(dir.path(), 0);
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&out).expect("opens").doc;
|
||||
worker.unlock(doc, "user").expect("unlocks");
|
||||
assert!(!worker.owner(doc, "nope").expect("tries"));
|
||||
assert_eq!(worker.pages(doc).expect("still open"), 1);
|
||||
assert!(worker.text(doc, 0).is_ok());
|
||||
assert!(worker.owner(doc, "owner").expect("tries"));
|
||||
assert_ne!(worker.permissions(doc).expect("reads") & may::OWNER, 0);
|
||||
worker.rotate_pages(doc, &[0], 90).expect("allowed now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_protection_needs_the_owner() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let out = locked(dir.path(), may::PRINT | may::COPY | may::EDIT);
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&out).expect("opens").doc;
|
||||
worker.unlock(doc, "user").expect("unlocks");
|
||||
let bare = dir.path().join("bare.pdf");
|
||||
let refused = worker.protect(doc, &bare, None).expect_err("refused");
|
||||
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
|
||||
assert!(!bare.exists());
|
||||
worker.owner(doc, "owner").expect("tries");
|
||||
worker.protect(doc, &bare, None).expect("unprotects");
|
||||
let mut other = self::worker();
|
||||
assert!(!other.open(&bare).expect("opens").locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_password_is_needed_and_must_differ() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = plain(dir.path());
|
||||
let out = dir.path().join("out.pdf");
|
||||
for (user, owner) in [("a", ""), ("same", "same")] {
|
||||
let lock = Lock { user: user.into(), owner: owner.into(), allow: 0 };
|
||||
assert!(worker.protect(doc, &out, Some(&lock)).is_err(), "{user}/{owner}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_open_password_still_holds_limits() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = plain(dir.path());
|
||||
let out = dir.path().join("out.pdf");
|
||||
let lock = Lock { user: String::new(), owner: "owner".into(), allow: may::PRINT };
|
||||
worker.protect(doc, &out, Some(&lock)).expect("protects");
|
||||
let mut other = self::worker();
|
||||
let opened = other.open(&out).expect("opens");
|
||||
assert!(!opened.locked);
|
||||
let refused = other.annotate(opened.doc, &[]).expect_err("refused");
|
||||
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "annotate".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_owner_try_on_aes_128_keeps_the_text_readable() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/aes128.pdf");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&fixture).expect("opens").doc;
|
||||
assert!(worker.unlock(doc, "user").expect("tries"));
|
||||
assert!(!worker.owner(doc, "nope").expect("tries"));
|
||||
let text: String = worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect();
|
||||
assert_eq!(text.trim(), "Hello");
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Signing by update and checking what was signed (PDF-12, QA-15).
|
||||
|
||||
use std::{io::ErrorKind, path::{Path, PathBuf}};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Digest, Doc, Lock, Signing, Worker,
|
||||
proto::may,
|
||||
sample::{Extras, one_page, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn plain(dir: &Path) -> PathBuf {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes");
|
||||
path
|
||||
}
|
||||
|
||||
fn signing(object: Option<u32>, name: &str, password: &str) -> Signing {
|
||||
let pfx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.p12");
|
||||
Signing {
|
||||
page: 0,
|
||||
object,
|
||||
rect: [72.0, 600.0, 272.0, 660.0],
|
||||
name: name.into(),
|
||||
pfx: pfx.to_str().expect("UTF-8").into(),
|
||||
pfx_password: password.into(),
|
||||
reason: "Approved".into(),
|
||||
location: "Here".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_signature_checks_and_the_document_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = plain(dir.path());
|
||||
let (mut worker, doc) = opened(&path);
|
||||
let before = worker.history(doc).expect("history");
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
assert!(worker.signatures(doc, &path).expect("reads").is_empty(), "the open document has none");
|
||||
assert_eq!(worker.history(doc).expect("history"), before);
|
||||
let (mut other, read) = opened(&out);
|
||||
let found = other.signatures(read, &out).expect("reads");
|
||||
assert_eq!(found.len(), 1);
|
||||
let signed = found[0].signed.as_ref().expect("signed");
|
||||
assert_eq!((signed.digest, signed.later, signed.broken), (Digest::Intact, false, false));
|
||||
assert_eq!((signed.common.as_str(), signed.reason.as_str(), signed.location.as_str()), ("Ely Test Signer", "Approved", "Here"));
|
||||
assert!(signed.date.starts_with("D:"), "{}", signed.date);
|
||||
assert_eq!(found[0].page, Some(0), "found through the page's annotations");
|
||||
assert!(signed.certs.first().is_some_and(|der| der.first() == Some(&0x30)), "DER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_certificate_password_writes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
assert!(worker.sign(doc, &out, &signing(None, "Signature1", "nope")).is_err());
|
||||
assert!(!out.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_byte_shows_as_changed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let mut bytes = std::fs::read(&out).expect("reads");
|
||||
let at = bytes.windows(5).position(|w| w == b"Hello").expect("the text");
|
||||
bytes[at] = b'J';
|
||||
std::fs::write(&out, bytes).expect("writes");
|
||||
let (mut other, read) = opened(&out);
|
||||
let signed = other.signatures(read, &out).expect("reads")[0].signed.clone().expect("signed");
|
||||
assert_eq!(signed.digest, Digest::Changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_signature_leaves_the_first_intact_and_marked_later() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let once = dir.path().join("once.pdf");
|
||||
worker.sign(doc, &once, &signing(None, "First", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&once);
|
||||
let twice = dir.path().join("twice.pdf");
|
||||
let mut second = signing(None, "Second", "ely");
|
||||
second.rect = [300.0, 600.0, 500.0, 660.0];
|
||||
other.sign(read, &twice, &second).expect("signs again");
|
||||
let (mut third, last) = opened(&twice);
|
||||
let found = third.signatures(last, &twice).expect("reads");
|
||||
let first = found.iter().find(|s| s.name == "First").and_then(|s| s.signed.clone()).expect("first");
|
||||
assert_eq!((first.digest, first.later), (Digest::Intact, true));
|
||||
let next = found.iter().find(|s| s.name == "Second").and_then(|s| s.signed.clone()).expect("second");
|
||||
assert_eq!((next.digest, next.later), (Digest::Intact, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_field_is_signed_in_place() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let field = "<< /Type /Annot /Subtype /Widget /FT /Sig /T (Sign here) /Rect [72 600 272 660] /P 3 0 R /F 4 >>".to_string();
|
||||
let path = dir.path().join("form.pdf");
|
||||
std::fs::write(&path, one_page(" /AcroForm << /Fields [5 0 R] /SigFlags 3 >>", " /Annots [5 0 R]", "", &[field])).expect("writes");
|
||||
let (mut worker, doc) = opened(&path);
|
||||
let empty = worker.signatures(doc, &path).expect("reads");
|
||||
assert_eq!((empty.len(), empty[0].signed.is_none(), empty[0].name.as_str()), (1, true, "Sign here"));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(Some(empty[0].object), "", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&out);
|
||||
let found = other.signatures(read, &out).expect("reads");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].signed.as_ref().map(|s| s.digest), Some(Digest::Intact));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_field_needs_leave_to_change_the_page() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let limited = dir.path().join("limited.pdf");
|
||||
let lock = Lock { user: String::new(), owner: "owner".into(), allow: may::PRINT | may::FILL };
|
||||
worker.protect(doc, &limited, Some(&lock)).expect("protects");
|
||||
let (mut other, read) = opened(&limited);
|
||||
let refused = other.sign(read, &dir.path().join("signed.pdf"), &signing(None, "S", "ely")).expect_err("refused");
|
||||
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_save_of_a_signed_file_carries_no_signature() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let signed = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&signed);
|
||||
assert_eq!(other.signed(read).expect("counts"), 1);
|
||||
other.rotate_pages(read, &[0], 90).expect("edits");
|
||||
let copy = dir.path().join("copy.pdf");
|
||||
other.save(read, ©).expect("saves");
|
||||
let (mut third, again) = opened(©);
|
||||
assert_eq!(third.signed(again).expect("counts"), 0);
|
||||
let fields = third.signatures(again, ©).expect("reads");
|
||||
assert_eq!((fields.len(), fields[0].signed.is_none()), (1, true), "the field stays, empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signer_is_the_certificate_given() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&out);
|
||||
let signed = other.signatures(read, &out).expect("reads")[0].signed.clone().expect("signed");
|
||||
let der = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.der")).expect("reads");
|
||||
assert_eq!(signed.certs, vec![der]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_signature_writes_in_the_script_face_and_says_what_it_lacks() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&plain(dir.path()));
|
||||
let writing = |text: &str| ely_pdf_engine::Writing {
|
||||
block: None,
|
||||
expected: String::new(),
|
||||
text: text.into(),
|
||||
font: "script".into(),
|
||||
size: 28.0,
|
||||
rgb: 0,
|
||||
bx: [72.0, 600.0, 400.0],
|
||||
};
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing("Ada Lovelace"), 0.0).expect("writes"), ely_pdf_engine::Wrote::Done(_)));
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let text: String = worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect();
|
||||
assert!(text.contains("Ada Lovelace"), "{text}");
|
||||
let saved = dir.path().join("typed.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let bytes = std::fs::read(&saved).expect("reads");
|
||||
assert!(bytes.windows(10).any(|w| w == b"GreatVibes"), "the script face is embedded");
|
||||
assert!(matches!(worker.put_text(doc, 0, &writing("王"), 0.0).expect("asks"), ely_pdf_engine::Wrote::Missing('王')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signatures_are_read_from_the_file_named() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let signed = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&signed);
|
||||
let (mark, copy) = (other.history(read).expect("history"), dir.path().join("copy.pdf"));
|
||||
other.save(read, ©).expect("saves");
|
||||
assert_eq!(other.history(read).expect("history"), mark, "saving edits nothing");
|
||||
assert!(other.signatures(read, ©).expect("reads")[0].signed.is_none(), "the copy holds none");
|
||||
assert!(other.signatures(read, &signed).expect("reads")[0].signed.is_some(), "the signed file still does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_on_no_page_is_still_listed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let out = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &out, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let mut bytes = std::fs::read(&out).expect("reads");
|
||||
// No page lists the widget; the form still does.
|
||||
let at = bytes.windows(7).rposition(|w| w == b"/Annots").expect("the page's annotations");
|
||||
bytes[at + 1] = b'X';
|
||||
std::fs::write(&out, bytes).expect("writes");
|
||||
let (mut other, read) = opened(&out);
|
||||
let found = other.signatures(read, &out).expect("reads");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!((found[0].page, found[0].signed.is_some()), (None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_leaves_the_history_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&plain(dir.path()));
|
||||
assert!(worker.save(doc, &dir.path().join("missing/out.pdf")).is_err());
|
||||
let mark = worker.rotate_pages(doc, &[0], 90).expect("rotates");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_ne!(mark, "0", "the edit is a step of its own");
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_emptied_by_a_save_can_be_signed_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&plain(dir.path()));
|
||||
let signed = dir.path().join("signed.pdf");
|
||||
worker.sign(doc, &signed, &signing(None, "Signature1", "ely")).expect("signs");
|
||||
let (mut other, read) = opened(&signed);
|
||||
let copy = dir.path().join("copy.pdf");
|
||||
other.save(read, ©).expect("saves");
|
||||
let (mut third, again) = opened(©);
|
||||
let field = third.signatures(again, ©).expect("reads")[0].clone();
|
||||
let out = dir.path().join("resigned.pdf");
|
||||
third.sign(again, &out, &signing(Some(field.object), "", "ely")).expect("signs the emptied field");
|
||||
let found = third.signatures(again, &out).expect("reads");
|
||||
assert_eq!(found[0].signed.as_ref().map(|s| s.digest), Some(Digest::Intact));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Bookmarks, links, properties, attachments against the real worker (PDF-07).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
BookmarkChange, Doc, LinkChange, Target, Worker,
|
||||
sample::{Extras, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// Two pages, bookmarks One (with Sub) and Two, a link.
|
||||
fn opened(path: &Path) -> (Worker, Doc) {
|
||||
let pages: [(f32, f32, &[&str]); 2] = [(612.0, 792.0, &["Page one"]), (612.0, 792.0, &["Page two"])];
|
||||
let marks = [("One", 0, &[("Sub", 1)][..]), ("Two", 1, &[][..])];
|
||||
std::fs::write(path, pdf_with(&pages, &marks, &Extras { links: &[(0, 1)], ..Extras::default() })).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
/// The outline as indented titles with their pages.
|
||||
fn tree(worker: &mut Worker, doc: Doc) -> Vec<String> {
|
||||
fn walk(marks: &[ely_pdf_engine::Bookmark], depth: usize, out: &mut Vec<String>) {
|
||||
for mark in marks {
|
||||
out.push(format!("{}{} {}", "-".repeat(depth), mark.title, mark.page.map_or(-1, |p| p as i64)));
|
||||
walk(&mark.down, depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&worker.outline(doc).expect("outline"), 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_add_rename_retarget_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert_eq!(tree(&mut worker, doc), ["One 0", "-Sub 1", "Two 1"]);
|
||||
worker.change_bookmark(doc, &BookmarkChange::Add { path: vec![2], title: "Three".into(), page: 1, y: 100.0 }).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.change_bookmark(doc, &BookmarkChange::Add { path: vec![0, 1], title: "Under".into(), page: 0, y: 0.0 }).expect("adds below");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let rename = BookmarkChange::Edit { path: vec![1], expect: "Two".into(), title: Some("Deux".into()), to: Some((0, 50.0)) };
|
||||
worker.change_bookmark(doc, &rename).expect("edits");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(tree(&mut worker, doc), ["One 0", "-Sub 1", "-Under 0", "Deux 0", "Three 1"]);
|
||||
assert!(worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![1], expect: "Two".into() }).is_err(), "renamed since");
|
||||
assert!(worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![9], expect: "One".into() }).is_err(), "no such place");
|
||||
worker.change_bookmark(doc, &BookmarkChange::Delete { path: vec![0], expect: "One".into() }).expect("deletes with its children");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(tree(&mut worker, doc), ["Deux 0", "Three 1"]);
|
||||
worker.undo(doc, false).expect("undoes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(tree(&mut worker, doc).len(), 5, "one step");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(tree(&mut other, reopened), ["One 0", "-Sub 1", "-Under 0", "Deux 0", "Three 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_list_add_retarget_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let links = worker.links(doc, 0).expect("links");
|
||||
assert_eq!((links.len(), links[0].page), (1, Some(1)), "the sample links to page two");
|
||||
let web = Target::Web("https://example.com/".into());
|
||||
worker.change_link(doc, 0, &LinkChange::Add { rect: [72.0, 300.0, 200.0, 320.0], to: web.clone() }).expect("adds");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let links = worker.links(doc, 0).expect("links");
|
||||
assert_eq!((links[1].uri.as_str(), links[1].page), ("https://example.com/", None));
|
||||
let first = links[0].uri.clone();
|
||||
worker.change_link(doc, 0, &LinkChange::Edit { index: 0, expect: first.clone(), to: Target::Page(0, 200.0) }).expect("retargets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.links(doc, 0).expect("links")[0].page, Some(0));
|
||||
assert!(worker.change_link(doc, 0, &LinkChange::Delete { index: 0, expect: first }).is_err(), "changed since");
|
||||
for unsafe_uri in ["javascript:alert(1)", "file:///etc/passwd"] {
|
||||
let add = LinkChange::Add { rect: [72.0, 400.0, 200.0, 420.0], to: Target::Web(unsafe_uri.into()) };
|
||||
assert!(worker.change_link(doc, 0, &add).is_err(), "{unsafe_uri}");
|
||||
}
|
||||
let now = worker.links(doc, 0).expect("links")[1].uri.clone();
|
||||
worker.change_link(doc, 0, &LinkChange::Delete { index: 1, expect: now }).expect("deletes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert_eq!(worker.links(doc, 0).expect("links").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_read_set_and_keep() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert_eq!(worker.properties(doc).expect("reads").title, None, "the sample has none");
|
||||
worker.set_properties(doc, &[("Title", "Report"), ("Author", "Zoe")]).expect("sets");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let read = worker.properties(doc).expect("reads");
|
||||
assert_eq!((read.title.as_deref(), read.author.as_deref()), (Some("Report"), Some("Zoe")));
|
||||
assert!(worker.set_properties(doc, &[("Title", "x"), ("Producer", "Ely")]).is_err(), "the producer is the producer's");
|
||||
assert_eq!(worker.properties(doc).expect("reads").title.as_deref(), Some("Report"), "nothing changed");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(other.properties(reopened).expect("reads").author.as_deref(), Some("Zoe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_embed_read_and_go() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
assert!(worker.attachments(doc).expect("lists").is_empty());
|
||||
let file = dir.path().join("notes.txt");
|
||||
std::fs::write(&file, "seven words of notes for the file").expect("writes");
|
||||
worker.attach(doc, "notes.txt", &file).expect("attaches");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
worker.attach(doc, "b.txt", &file).expect("attaches another");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let list = worker.attachments(doc).expect("lists");
|
||||
let names: Vec<(&str, u64)> = list.iter().map(|a| (a.name.as_str(), a.size)).collect();
|
||||
assert_eq!(names, [("b.txt", 33), ("notes.txt", 33)], "by name");
|
||||
assert_eq!(worker.attachment(doc, "n:notes.txt").expect("reads"), b"seven words of notes for the file");
|
||||
assert!(worker.attach(doc, "notes.txt", &file).is_err(), "the name is taken");
|
||||
worker.detach(doc, "n:b.txt").expect("removes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.attachment(doc, "n:b.txt").is_err());
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
let kept: Vec<String> = other.attachments(reopened).expect("lists").into_iter().map(|a| a.name).collect();
|
||||
assert_eq!(kept, ["notes.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_on_a_page_lists_reads_and_goes_by_its_key() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("doc.pdf");
|
||||
let extras = Extras { attached: Some(("note.txt", b"notes")), ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let mut doc = worker.open(&path).expect("opens").doc;
|
||||
let list = worker.attachments(doc).expect("lists");
|
||||
assert_eq!(list.len(), 1, "the page's file is an attachment");
|
||||
assert_eq!((list[0].name.as_str(), list[0].page, list[0].size), ("note.txt", Some(0), 5), "its size from its bytes");
|
||||
assert!(list[0].key.starts_with("a:0:"));
|
||||
assert_eq!(worker.attachment(doc, &list[0].key).expect("reads"), b"notes");
|
||||
worker.detach(doc, &list[0].key).expect("removes");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
assert!(worker.attachments(doc).expect("lists").is_empty());
|
||||
assert!(worker.marks(doc, 0).expect("marks").annots.is_empty(), "with its annotation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_an_attachment_leaves_its_bytes_in_the_document() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, mut doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let file = dir.path().join("note.txt");
|
||||
std::fs::write(&file, "notes").expect("writes");
|
||||
worker.attach(doc, "note.txt", &file).expect("attaches");
|
||||
doc = worker.now(doc).expect("the state");
|
||||
let mark = worker.history(doc).expect("a mark");
|
||||
for _ in 0..2 {
|
||||
assert_eq!(worker.attachment(doc, "n:note.txt").expect("reads"), b"notes");
|
||||
}
|
||||
assert_eq!(worker.history(doc).expect("a mark"), mark, "reading changes nothing");
|
||||
let saved = dir.path().join("saved.pdf");
|
||||
worker.save(doc, &saved).expect("saves");
|
||||
let mut other = self::worker();
|
||||
let reopened = other.open(&saved).expect("opens").doc;
|
||||
assert_eq!(other.attachment(reopened, "n:note.txt").expect("reads"), b"notes", "whole in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_read_before_a_change_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(&dir.path().join("doc.pdf"));
|
||||
let (old, new) = (dir.path().join("old.txt"), dir.path().join("new.txt"));
|
||||
std::fs::write(&old, "old report").expect("writes");
|
||||
std::fs::write(&new, "new report").expect("writes");
|
||||
let first = doc.at(&worker.attach(doc, "report.txt", &old).expect("attaches")).expect("the state");
|
||||
let gone = first.at(&worker.detach(first, "n:report.txt").expect("removes")).expect("the state");
|
||||
let now = gone.at(&worker.attach(gone, "report.txt", &new).expect("attaches again")).expect("the state");
|
||||
// The same key names another file now: refused.
|
||||
let read = worker.attachment(first, "n:report.txt").expect_err("an older state");
|
||||
assert!(ely_pdf_engine::is_stale(&read), "{read}");
|
||||
let removed = worker.detach(first, "n:report.txt").expect_err("an older state");
|
||||
assert!(ely_pdf_engine::is_stale(&removed), "{removed}");
|
||||
assert_eq!(worker.attachment(now, "n:report.txt").expect("reads"), b"new report", "the new file stays");
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The worker against generated PDFs.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{Doc, Worker, sample::pdf};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
|
||||
fn opened(dir: &Path, bytes: &[u8]) -> (Worker, Doc) {
|
||||
let path = dir.join("doc.pdf");
|
||||
std::fs::write(&path, bytes).expect("writes");
|
||||
let mut worker = worker();
|
||||
let opened = worker.open(&path).expect("opens");
|
||||
assert!(!opened.locked);
|
||||
(worker, opened.doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_measure_render_and_read_their_text() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let bytes = pdf(
|
||||
&[
|
||||
(612.0, 792.0, &["Ely Office contract", "Second line"]),
|
||||
(842.0, 595.0, &["Wide page"]),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
let (mut worker, doc) = opened(dir.path(), &bytes);
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 2);
|
||||
assert_eq!(
|
||||
worker.bounds(doc, 0, 2).expect("bounds"),
|
||||
[[0.0, 0.0, 612.0, 792.0], [0.0, 0.0, 842.0, 595.0]]
|
||||
);
|
||||
// Page one's top left, twice its size: text is dark.
|
||||
let tile = worker
|
||||
.render(doc, 0, (2.0, 0), [0, 0, 600, 300], false)
|
||||
.expect("renders").bgra;
|
||||
assert_eq!(tile.len(), 600 * 300 * 4);
|
||||
let dark = tile.chunks_exact(4).filter(|p| p[0] < 128).count();
|
||||
assert!(dark > 100, "text shows: {dark}");
|
||||
let night = worker
|
||||
.render(doc, 0, (2.0, 0), [0, 0, 600, 300], true)
|
||||
.expect("renders dark").bgra;
|
||||
let light = night.chunks_exact(4).filter(|p| p[0] > 128).count();
|
||||
assert!(
|
||||
light > 100 && night[0] < 64,
|
||||
"dark mode turns paper dark and text light"
|
||||
);
|
||||
// A quarter turn makes the page tall side wide.
|
||||
assert!(
|
||||
worker
|
||||
.render(doc, 1, (1.0, 90), [0, 0, 595, 842], false)
|
||||
.is_ok()
|
||||
);
|
||||
let text: String = worker
|
||||
.text(doc, 0)
|
||||
.expect("reads")
|
||||
.iter()
|
||||
.map(|c| c.c)
|
||||
.collect();
|
||||
assert!(
|
||||
text.contains("Ely Office contract") && text.contains("Second line"),
|
||||
"{text}"
|
||||
);
|
||||
let chars = worker.text(doc, 0).expect("reads");
|
||||
assert!(
|
||||
chars.windows(2).all(|w| w[0].line <= w[1].line),
|
||||
"reading order"
|
||||
);
|
||||
assert!(
|
||||
chars[0].rect[1] < chars.last().expect("chars").rect[1],
|
||||
"first line above"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_every_hit_and_bookmarks_nest() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let pages: &[(f32, f32, &[&str])] = &[
|
||||
(612.0, 792.0, &["alpha beta alpha"]),
|
||||
(612.0, 792.0, &["no match here"]),
|
||||
(612.0, 792.0, &["Alpha at last"]),
|
||||
];
|
||||
let bytes = pdf(pages, &[("Intro", 0, &[("Detail", 1)]), ("End", 2, &[])]);
|
||||
let (mut worker, doc) = opened(dir.path(), &bytes);
|
||||
let hits: Vec<usize> = (0..3)
|
||||
.map(|page| worker.search(doc, page, "alpha").expect("searches").len())
|
||||
.collect();
|
||||
assert_eq!(hits, [2, 0, 1], "case is ignored");
|
||||
let quad = worker.search(doc, 0, "beta").expect("searches")[0][0];
|
||||
assert!(quad[0] > 72.0 && quad[1] > 0.0, "{quad:?}");
|
||||
let marks = worker.outline(doc).expect("outline");
|
||||
let flat: Vec<(&str, Option<usize>, usize)> = marks
|
||||
.iter()
|
||||
.map(|m| (m.title.as_str(), m.page, m.down.len()))
|
||||
.collect();
|
||||
assert_eq!(flat, [("Intro", Some(0), 1), ("End", Some(2), 0)]);
|
||||
assert_eq!(
|
||||
(marks[0].down[0].title.as_str(), marks[0].down[0].page),
|
||||
("Detail", Some(1))
|
||||
);
|
||||
worker.close(doc).expect("closes");
|
||||
assert!(worker.pages(doc).is_err(), "a closed document is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_file_is_an_error_and_the_worker_lives_on() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("junk.pdf");
|
||||
std::fs::write(&path, b"this is not a pdf at all").expect("writes");
|
||||
let mut worker = worker();
|
||||
let failed = worker.open(&path).expect_err("not a PDF");
|
||||
assert!(!failed.to_string().is_empty());
|
||||
assert!(worker.open(&dir.path().join("missing.pdf")).is_err());
|
||||
let good = dir.path().join("good.pdf");
|
||||
std::fs::write(&good, pdf(&[(612.0, 792.0, &["Still here"])], &[])).expect("writes");
|
||||
assert_eq!(
|
||||
worker.open(&good).map(|o| o.locked).ok(),
|
||||
Some(false),
|
||||
"the worker still works"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thousand_pages_show_the_first_in_time() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let lines: &[&str] = &["A long report, page after page."];
|
||||
let pages: Vec<(f32, f32, &[&str])> = (0..1000).map(|_| (612.0, 792.0, lines)).collect();
|
||||
let bytes = pdf(&pages, &[]);
|
||||
let started = std::time::Instant::now();
|
||||
let (mut worker, doc) = opened(dir.path(), &bytes);
|
||||
assert_eq!(worker.pages(doc).expect("counts"), 1000);
|
||||
// What the viewer asks before its first frame.
|
||||
worker.bounds(doc, 0, 100).expect("the first sizes");
|
||||
worker.render(doc, 0, (2.0, 0), [0, 0, 512, 512], false).expect("the first tile");
|
||||
let took = started.elapsed();
|
||||
assert!(took < std::time::Duration::from_secs(3), "PERF-05: {took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_hit_counts_once_however_many_or_however_split() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let busy = ["alpha "; 12].concat();
|
||||
let many: Vec<&str> = (0..50).map(|_| busy.as_str()).collect();
|
||||
let pages: &[(f32, f32, &[&str])] = &[(1000.0, 1400.0, &many), (612.0, 792.0, &["hello", "world"])];
|
||||
let (mut worker, doc) = opened(dir.path(), &pdf(pages, &[]));
|
||||
assert_eq!(worker.search(doc, 0, "alpha").expect("searches").len(), 600, "no cap on hits");
|
||||
let split = worker.search(doc, 1, "hello world").expect("searches");
|
||||
assert_eq!(split.len(), 1, "one phrase over two lines");
|
||||
assert_eq!(split[0].len(), 2, "with a quad on each line");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_worker_from_other_sources_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
// Answers HELLO with someone else's stamp.
|
||||
let stale = dir.path().join("stale-worker");
|
||||
std::fs::write(
|
||||
&stale,
|
||||
"#!/usr/bin/env python3\nimport struct, sys\nhead = sys.stdin.buffer.read(13)\nstamp = b'old'\nbody = struct.pack('<I', len(stamp)) + stamp\nsys.stdout.buffer.write(struct.pack('<I', len(body)) + bytes([100]) + head[5:13] + body)\nsys.stdout.flush()\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&stale, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("executable");
|
||||
let refused = Worker::spawn_from(&stale).err().expect("refused");
|
||||
assert!(refused.to_string().contains("other sources"), "{refused}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_reply_to_another_request_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
// Answers HELLO under the next request's id.
|
||||
let crossed = dir.path().join("crossed-worker");
|
||||
std::fs::write(
|
||||
&crossed,
|
||||
"#!/usr/bin/env python3\nimport struct, sys\nhead = sys.stdin.buffer.read(13)\nasked = struct.unpack('<Q', head[5:13])[0]\nbody = struct.pack('<I', 3) + b'any'\nsys.stdout.buffer.write(struct.pack('<I', len(body)) + bytes([100]) + struct.pack('<Q', asked + 1) + body)\nsys.stdout.flush()\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&crossed, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("executable");
|
||||
let refused = Worker::spawn_from(&crossed).err().expect("refused");
|
||||
assert!(refused.to_string().contains("not 1"), "{refused}");
|
||||
}
|
||||
|
||||
/// A noisy RGB PNG, stored uncompressed: large on disk.
|
||||
fn big_png(path: &Path, side: u32) {
|
||||
let crc = |bytes: &[u8]| {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for byte in bytes {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xedb8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
};
|
||||
let chunk = |kind: &[u8], data: &[u8]| {
|
||||
let body = [kind, data].concat();
|
||||
[&(data.len() as u32).to_be_bytes()[..], &body, &crc(&body).to_be_bytes()].concat()
|
||||
};
|
||||
let mut raw = Vec::new();
|
||||
let mut seed = 7u32;
|
||||
for _ in 0..side {
|
||||
raw.push(0);
|
||||
for _ in 0..side * 3 {
|
||||
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
raw.push((seed >> 16) as u8);
|
||||
}
|
||||
}
|
||||
// zlib with stored blocks; Adler-32 at the end.
|
||||
let mut zlib = vec![0x78, 0x01];
|
||||
let blocks: Vec<&[u8]> = raw.chunks(65_535).collect();
|
||||
for (n, block) in blocks.iter().enumerate() {
|
||||
zlib.push(u8::from(n + 1 == blocks.len()));
|
||||
let len = block.len() as u16;
|
||||
zlib.extend_from_slice(&len.to_le_bytes());
|
||||
zlib.extend_from_slice(&(!len).to_le_bytes());
|
||||
zlib.extend_from_slice(block);
|
||||
}
|
||||
let (mut a, mut b) = (1u32, 0u32);
|
||||
for byte in &raw {
|
||||
a = (a + u32::from(*byte)) % 65_521;
|
||||
b = (b + a) % 65_521;
|
||||
}
|
||||
zlib.extend_from_slice(&((b << 16) | a).to_be_bytes());
|
||||
let header = [&side.to_be_bytes()[..], &side.to_be_bytes(), &[8, 2, 0, 0, 0]].concat();
|
||||
let png = [&b"\x89PNG\r\n\x1a\n"[..], &chunk(b"IHDR", &header), &chunk(b"IDAT", &zlib), &chunk(b"IEND", &[])].concat();
|
||||
std::fs::write(path, png).expect("writes");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failed_open_keeps_no_memory() {
|
||||
// An image MuPDF opens, then refuses to journal.
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let png = dir.path().join("big.png");
|
||||
big_png(&png, 1200);
|
||||
let mut worker = Worker::spawn().expect("a worker");
|
||||
let rss = |pid: u32| -> u64 {
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid.to_string()]).output().expect("ps");
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().expect("kilobytes")
|
||||
};
|
||||
// MuPDF's 256 MB store fills first; then nothing grows.
|
||||
for _ in 0..70 {
|
||||
let refused = worker.open(&png).err().expect("not a PDF");
|
||||
assert!(refused.to_string().contains("not a PDF"), "{refused}");
|
||||
}
|
||||
let before = rss(worker.pid());
|
||||
for _ in 0..50 {
|
||||
assert!(worker.open(&png).is_err(), "not a PDF");
|
||||
}
|
||||
let grown = rss(worker.pid()).saturating_sub(before);
|
||||
assert!(grown < 40_000, "50 more failed opens of a 4 MB image kept {grown} KiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_belongs_to_its_worker() {
|
||||
let first = tempfile::tempdir().expect("a temp dir");
|
||||
let (old_worker, old) = opened(first.path(), &pdf(&[(300.0, 500.0, &["A"])], &[]));
|
||||
drop(old_worker);
|
||||
let second = tempfile::tempdir().expect("a temp dir");
|
||||
let (mut worker, doc) = opened(second.path(), &pdf(&[(300.0, 500.0, &["B"])], &[]));
|
||||
// An old worker's handle never reaches this one's documents.
|
||||
assert!(worker.close(old).is_err(), "another worker's document closed");
|
||||
assert!(worker.pages(old).is_err(), "another worker's document answered");
|
||||
assert_eq!(worker.pages(doc).expect("its own document"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_made_for_an_older_state_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let pages: &[(f32, f32, &[&str])] = &[(300.0, 500.0, &["A"]), (300.0, 500.0, &["B"]), (300.0, 500.0, &["C"])];
|
||||
let (mut worker, before) = opened(dir.path(), &pdf(pages, &[]));
|
||||
let mark = worker.delete_pages(before, &[0]).expect("deletes A");
|
||||
let after = before.at(&mark).expect("the new state");
|
||||
// Page 1 was B, now C; the old aim fails.
|
||||
let stale = worker.rotate_pages(before, &[1], 90).expect_err("an older state");
|
||||
assert!(ely_pdf_engine::is_stale(&stale), "{stale}");
|
||||
assert!(worker.bounds(before, 0, 1).is_err(), "a read for an older state");
|
||||
assert_eq!(worker.bounds(after, 1, 1).expect("C")[0], [0.0, 0.0, 300.0, 500.0], "C stayed upright");
|
||||
worker.rotate_pages(after, &[1], 90).expect("rotates C");
|
||||
// Whole-document requests take any state.
|
||||
assert_eq!(worker.pages(before).expect("pages"), 2);
|
||||
}
|
||||
Reference in new issue
Block a user