mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
M10.2/M11.1: Create a PDF (blank, from PNG/JPEG images at their resolution with transparency, from plain text) in a new create crate (L4); opening an image or text file converts it to a new unsaved PDF; Reduce file size (resource merge across the document, GC, object streams) writes a compact copy; Create group Ready; doc_create/doc_reduce tools (49 tools). Fix: the Hand tool did not pan (the page widget took the drag)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c69c02699
commit
adb644c5e1
Generated
+12
@@ -3072,6 +3072,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "printcraft-create"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"png 0.18.1",
|
||||
"printcraft-cos",
|
||||
"printcraft-fonts",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "printcraft-crypt"
|
||||
version = "0.1.0"
|
||||
@@ -3103,6 +3114,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"printcraft-annot",
|
||||
"printcraft-cos",
|
||||
"printcraft-create",
|
||||
"printcraft-edit",
|
||||
"printcraft-forms",
|
||||
"printcraft-model",
|
||||
|
||||
@@ -22,6 +22,7 @@ printcraft-annot = { path = "crates/annot", version = "0.1.0" }
|
||||
printcraft-fonts = { path = "crates/fonts", version = "0.1.0" }
|
||||
printcraft-model = { path = "crates/model", version = "0.1.0" }
|
||||
printcraft-edit = { path = "crates/edit", version = "0.1.0" }
|
||||
printcraft-create = { path = "crates/create", version = "0.1.0" }
|
||||
printcraft-forms = { path = "crates/forms", version = "0.1.0" }
|
||||
printcraft-render = { path = "crates/render", version = "0.1.0" }
|
||||
printcraft-engine = { path = "crates/engine", version = "0.1.0" }
|
||||
|
||||
@@ -216,6 +216,15 @@ impl Automation {
|
||||
self.apply(&a, Edit::DuplicatePages { pages })?
|
||||
}
|
||||
"page_set_box" => self.page_set_box(&a)?,
|
||||
"doc_create" => self.doc_create(&a)?,
|
||||
"doc_reduce" => {
|
||||
let id = self.doc(&a)?.id;
|
||||
let before = self.doc(&a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?;
|
||||
write_atomic(&path, &bytes)?;
|
||||
json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged })
|
||||
}
|
||||
"doc_export_images" | "doc_export_text" => self.export(name, &a)?,
|
||||
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
|
||||
"doc_unprotect" => {
|
||||
@@ -377,6 +386,46 @@ impl Automation {
|
||||
Ok(json!({ "count": files.len(), "files": files }))
|
||||
}
|
||||
|
||||
fn doc_create(&mut self, a: &Args) -> Result<Value> {
|
||||
let (name, bytes) = match a.str("from")? {
|
||||
"blank" => {
|
||||
let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize;
|
||||
let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0));
|
||||
("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?)
|
||||
}
|
||||
"images" => {
|
||||
let mut images = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes));
|
||||
}
|
||||
let name = if images.len() == 1 {
|
||||
format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s))
|
||||
} else {
|
||||
"Images.pdf".into()
|
||||
};
|
||||
(name, self.session.create_from_images(&images).map_err(failed)?)
|
||||
}
|
||||
"text" => {
|
||||
let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) {
|
||||
(Some(t), _) => ("Text".to_string(), t.to_string()),
|
||||
(None, Some(p)) => {
|
||||
let path = self.resolve(p, false)?;
|
||||
let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), String::from_utf8_lossy(&t).into_owned())
|
||||
}
|
||||
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
|
||||
};
|
||||
(format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?)
|
||||
}
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))),
|
||||
};
|
||||
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name);
|
||||
let id = self.session.open_new(name, bytes).map_err(failed)?;
|
||||
Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?))
|
||||
}
|
||||
|
||||
fn page_set_box(&mut self, a: &Args) -> Result<Value> {
|
||||
use printcraft_engine::{BoxSpec, PageBox};
|
||||
let doc = self.doc(a)?;
|
||||
|
||||
@@ -369,6 +369,27 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "page", "type", "at"],
|
||||
)),
|
||||
t(
|
||||
"doc_create",
|
||||
"Create a PDF",
|
||||
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG/JPEG files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly). Save it with doc_save and a path.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"from": { "type": "string", "enum": ["blank", "images", "text"] },
|
||||
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
||||
"text": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"pages": { "type": "integer", "minimum": 1, "maximum": 10000 },
|
||||
"width": { "type": "number", "minimum": 3 },
|
||||
"height": { "type": "number", "minimum": 3 },
|
||||
"name": { "type": "string", "description": "Name for the new document (default derived from the source)." },
|
||||
}),
|
||||
&["from"],
|
||||
)),
|
||||
t("doc_reduce", "Reduce file size", "Write a compacted copy of the document to `path` (identical fonts and images merged, unused objects dropped, compressed object streams). The open document is unchanged.")
|
||||
.cmd("optimize.reduce")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
|
||||
|
||||
@@ -547,3 +547,18 @@ fn fill_and_sign_through_tools() {
|
||||
assert_eq!(list["count"], 3);
|
||||
assert!(matches!(a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [1, 1] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_and_reducing_through_tools() {
|
||||
let dir = workdir("create");
|
||||
std::fs::write(dir.join("notes.txt"), "Meeting notes\nAction items").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "pages": 2 }));
|
||||
assert_eq!((blank["pages"].as_u64(), blank["dirty"].as_bool()), (Some(2), Some(true)));
|
||||
let t = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page_text(&mut a, t), ["Meeting notes\nAction items"]);
|
||||
ok(&mut a, "doc_save", json!({ "doc": t, "path": "notes.pdf" }));
|
||||
let r = ok(&mut a, "doc_reduce", json!({ "doc": t, "path": "notes-small.pdf" }));
|
||||
assert!(r["bytes_after"].as_u64().unwrap() > 0 && dir.join("notes-small.pdf").exists());
|
||||
assert!(matches!(a.call("doc_create", &json!({ "from": "images", "paths": ["notes.txt"] })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "printcraft-create"
|
||||
description = "Create PDFs: blank documents, from images (PNG, JPEG) and from plain text"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
printcraft-cos = { workspace = true }
|
||||
printcraft-fonts = { workspace = true }
|
||||
png = "0.18"
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
@@ -0,0 +1,12 @@
|
||||
# printcraft-create
|
||||
|
||||
Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
|
||||
|
||||
- `blank(width, height, pages)`: an empty document;
|
||||
- `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution
|
||||
(PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is
|
||||
(`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate,
|
||||
with transparency as a soft mask;
|
||||
- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated.
|
||||
|
||||
Every function returns a `printcraft_cos::Document`; the caller writes it.
|
||||
@@ -0,0 +1,286 @@
|
||||
//! printcraft-create — create PDFs from nothing, images or text (L4). See the README.
|
||||
|
||||
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use printcraft_fonts::{literal, win_ansi, wrap};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum CreateError {
|
||||
#[error("{0}: {1}")]
|
||||
Image(String, String),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
/// US Letter in points.
|
||||
pub const LETTER: (f64, f64) = (612.0, 792.0);
|
||||
|
||||
/// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units).
|
||||
const MAX_SIDE: f64 = 14_400.0;
|
||||
|
||||
fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) -> ObjRef {
|
||||
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).expect("new_empty has a page tree");
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)]));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
if let Some(c) = content {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c)));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(s));
|
||||
}
|
||||
let r = doc.add(page);
|
||||
let _ = doc.update_dict(pages, |d| {
|
||||
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
kids.push(Object::Ref(r));
|
||||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
fn set_title(doc: &mut Document, title: &str) {
|
||||
let mut info = Dict::new();
|
||||
info.set(b"Title".to_vec(), PdfString::text(title));
|
||||
info.set(b"Producer".to_vec(), PdfString::text("PrintCraft"));
|
||||
let r = doc.add(info);
|
||||
doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
|
||||
}
|
||||
|
||||
/// A document of `pages` empty pages of `width × height` points.
|
||||
pub fn blank(width: f64, height: f64, pages: usize) -> Result<Document, CreateError> {
|
||||
if !(width.is_finite() && height.is_finite() && (3.0..=MAX_SIDE).contains(&width) && (3.0..=MAX_SIDE).contains(&height))
|
||||
|| pages == 0
|
||||
|| pages > 10_000
|
||||
{
|
||||
return Err(CreateError::Invalid("invalid page size or count".into()));
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
for _ in 0..pages {
|
||||
add_page(&mut doc, width, height, Dict::new(), None);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
// ── images ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, and size
|
||||
/// in pixels and dots per inch.
|
||||
struct Embedded {
|
||||
dict: Dict,
|
||||
data: Vec<u8>,
|
||||
filtered: bool,
|
||||
smask: Option<(Dict, Vec<u8>)>,
|
||||
px: (u32, u32),
|
||||
dpi: (f64, f64),
|
||||
}
|
||||
|
||||
fn jpeg(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
||||
let bad = |m: &str| CreateError::Image(name.into(), m.into());
|
||||
if bytes.len() < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8 {
|
||||
return Err(bad("not a JPEG file"));
|
||||
}
|
||||
let (mut i, mut size, mut comps, mut dpi, mut adobe) = (2usize, None, 0u8, (72.0, 72.0), false);
|
||||
while i + 4 <= bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
let seg = bytes.get(i + 4..i + 2 + len).ok_or_else(|| bad("truncated"))?;
|
||||
match marker {
|
||||
// APP0 JFIF density.
|
||||
0xE0 if seg.starts_with(b"JFIF\0") && seg.len() >= 12 => {
|
||||
let (unit, x, y) = (seg[7], u16::from_be_bytes([seg[8], seg[9]]) as f64, u16::from_be_bytes([seg[10], seg[11]]) as f64);
|
||||
if x > 0.0 && y > 0.0 {
|
||||
dpi = match unit {
|
||||
1 => (x, y),
|
||||
2 => (x * 2.54, y * 2.54),
|
||||
_ => dpi,
|
||||
};
|
||||
}
|
||||
}
|
||||
0xEE if seg.starts_with(b"Adobe") => adobe = true,
|
||||
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
|
||||
if seg.len() < 6 {
|
||||
return Err(bad("bad frame header"));
|
||||
}
|
||||
size = Some((u16::from_be_bytes([seg[3], seg[4]]) as u32, u16::from_be_bytes([seg[1], seg[2]]) as u32));
|
||||
comps = seg[5];
|
||||
break;
|
||||
}
|
||||
0xDA => break,
|
||||
_ => {}
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
let (w, h) = size.filter(|(w, h)| *w > 0 && *h > 0).ok_or_else(|| bad("no image size"))?;
|
||||
let mut d = Dict::new();
|
||||
let cs = match comps {
|
||||
1 => "DeviceGray",
|
||||
3 => "DeviceRGB",
|
||||
4 => {
|
||||
if adobe {
|
||||
// Adobe writes CMYK JPEGs inverted.
|
||||
d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0, 1, 0].iter().map(|v| Object::Int(*v)).collect()));
|
||||
}
|
||||
"DeviceCMYK"
|
||||
}
|
||||
n => return Err(bad(&format!("{n} colour components are not supported"))),
|
||||
};
|
||||
d.set(b"ColorSpace".to_vec(), Object::name(cs));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
Ok(Embedded { dict: d, data: bytes.to_vec(), filtered: true, smask: None, px: (w, h), dpi })
|
||||
}
|
||||
|
||||
fn png_image(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
||||
let bad = |m: String| CreateError::Image(name.into(), m);
|
||||
let mut dec = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = dec.read_info().map_err(|e| bad(e.to_string()))?;
|
||||
let dpi = match reader.info().pixel_dims {
|
||||
Some(png::PixelDimensions { xppu, yppu, unit: png::Unit::Meter }) if xppu > 0 && yppu > 0 => (xppu as f64 * 0.0254, yppu as f64 * 0.0254),
|
||||
_ => (72.0, 72.0),
|
||||
};
|
||||
let mut buf = vec![0; reader.output_buffer_size().ok_or_else(|| bad("image too large".into()))?];
|
||||
let frame = reader.next_frame(&mut buf).map_err(|e| bad(e.to_string()))?;
|
||||
let (w, h) = (frame.width, frame.height);
|
||||
let data = &buf[..frame.buffer_size()];
|
||||
let (channels, cs) = match frame.color_type {
|
||||
png::ColorType::Grayscale => (1, "DeviceGray"),
|
||||
png::ColorType::GrayscaleAlpha => (2, "DeviceGray"),
|
||||
png::ColorType::Rgb => (3, "DeviceRGB"),
|
||||
png::ColorType::Rgba => (4, "DeviceRGB"),
|
||||
png::ColorType::Indexed => return Err(bad("unexpected palette output".into())),
|
||||
};
|
||||
let has_alpha = channels == 2 || channels == 4;
|
||||
let colour_n = if has_alpha { channels - 1 } else { channels };
|
||||
let mut colour = Vec::with_capacity(w as usize * h as usize * colour_n);
|
||||
let mut alpha = Vec::new();
|
||||
for px in data.chunks_exact(channels) {
|
||||
colour.extend_from_slice(&px[..colour_n]);
|
||||
if has_alpha {
|
||||
alpha.push(px[colour_n]);
|
||||
}
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
d.set(b"ColorSpace".to_vec(), Object::name(cs));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
let smask = (has_alpha && alpha.iter().any(|a| *a != 255)).then(|| {
|
||||
let mut m = Dict::new();
|
||||
m.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
m.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
m.set(b"Width".to_vec(), Object::Int(w as i64));
|
||||
m.set(b"Height".to_vec(), Object::Int(h as i64));
|
||||
m.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
m.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
(m, alpha)
|
||||
});
|
||||
Ok(Embedded { dict: d, data: colour, filtered: false, smask, px: (w, h), dpi })
|
||||
}
|
||||
|
||||
/// Detect the image format from its bytes.
|
||||
fn embed(name: &str, bytes: &[u8]) -> Result<Embedded, CreateError> {
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
jpeg(name, bytes)
|
||||
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
png_image(name, bytes)
|
||||
} else {
|
||||
Err(CreateError::Image(name.into(), "only PNG and JPEG images are supported so far".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One page per image, each the size of its image at the image's resolution.
|
||||
pub fn from_images(images: &[(String, Vec<u8>)]) -> Result<Document, CreateError> {
|
||||
if images.is_empty() {
|
||||
return Err(CreateError::Invalid("no images".into()));
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
for (name, bytes) in images {
|
||||
let img = embed(name, bytes)?;
|
||||
let (mut w, mut h) = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1);
|
||||
// Keep huge images within the largest page PDF allows.
|
||||
let k = (MAX_SIDE / w.max(h)).min(1.0);
|
||||
w *= k;
|
||||
h *= k;
|
||||
let mut d = img.dict;
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64));
|
||||
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
|
||||
if let Some((m, alpha)) = img.smask {
|
||||
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
|
||||
d.set(b"SMask".to_vec(), Object::Ref(mr));
|
||||
}
|
||||
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
|
||||
let xr = doc.add(Object::Stream(stream));
|
||||
let mut xobj = Dict::new();
|
||||
xobj.set(b"Im0".to_vec(), Object::Ref(xr));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobj));
|
||||
let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes();
|
||||
add_page(&mut doc, w, h, res, Some(content));
|
||||
}
|
||||
if let Some((name, _)) = images.first() {
|
||||
set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s));
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
// ── text ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins.
|
||||
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
|
||||
let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 };
|
||||
let (w, h) = page;
|
||||
let margin = 72.0;
|
||||
let line_h = size * 1.25;
|
||||
let width = (w - 2.0 * margin).max(36.0);
|
||||
let lines: Vec<String> = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\t', " ")
|
||||
.split('\u{c}')
|
||||
.flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string())))
|
||||
.collect();
|
||||
let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1);
|
||||
let mut doc = Document::new_empty();
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let fr = doc.add(font);
|
||||
let mut page_lines: Vec<Vec<String>> = vec![Vec::new()];
|
||||
for l in lines.iter().take(lines.len().saturating_sub(1)) {
|
||||
if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) {
|
||||
page_lines.push(Vec::new());
|
||||
if l == "\u{c}" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
page_lines.last_mut().expect("non-empty").push(l.clone());
|
||||
}
|
||||
for lines in page_lines {
|
||||
let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes();
|
||||
for l in lines {
|
||||
c.extend(literal(&win_ansi(&l)));
|
||||
c.extend_from_slice(b" Tj T*\n");
|
||||
}
|
||||
c.extend_from_slice(b"ET\n");
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(fr));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
add_page(&mut doc, w, h, res, Some(c));
|
||||
}
|
||||
set_title(&mut doc, title);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,99 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use printcraft_cos::{SaveOptions, write_full};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn reopen(doc: &Document) -> Document {
|
||||
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
|
||||
hayro_syntax::Pdf::new(bytes.clone()).expect("parses");
|
||||
Document::open(Arc::new(bytes)).unwrap()
|
||||
}
|
||||
|
||||
fn pages(doc: &Document) -> Vec<Dict> {
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let kids = doc.get(pages).as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap().clone();
|
||||
kids.iter().map(|k| doc.resolve(k).as_dict().cloned().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn media(d: &Dict) -> Vec<f64> {
|
||||
d.get(b"MediaBox").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// A tiny PNG: 4×2 RGBA with one transparent pixel, 144 dpi.
|
||||
fn png_bytes(alpha: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, 4, 2);
|
||||
enc.set_color(if alpha { png::ColorType::Rgba } else { png::ColorType::Rgb });
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
enc.set_pixel_dims(Some(png::PixelDimensions { xppu: 5669, yppu: 5669, unit: png::Unit::Meter }));
|
||||
let mut w = enc.write_header().unwrap();
|
||||
let n = if alpha { 4 } else { 3 };
|
||||
let mut data = vec![200u8; 8 * n];
|
||||
if alpha {
|
||||
data[3] = 0;
|
||||
}
|
||||
w.write_image_data(&data).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A minimal baseline JPEG header (SOI, JFIF at 300 dpi, SOF0 3×2 RGB, EOI). Only the headers
|
||||
/// matter: the data is embedded as is.
|
||||
fn jpeg_bytes() -> Vec<u8> {
|
||||
let mut v = vec![0xFF, 0xD8];
|
||||
v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]);
|
||||
v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_documents() {
|
||||
let doc = reopen(&blank(612.0, 792.0, 3).unwrap());
|
||||
assert_eq!(pages(&doc).len(), 3);
|
||||
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 612.0, 792.0]);
|
||||
assert!(blank(1.0, 792.0, 1).is_err() && blank(612.0, 792.0, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_become_pages_at_their_resolution() {
|
||||
let doc =
|
||||
from_images(&[("photo.png".into(), png_bytes(true)), ("scan.jpg".into(), jpeg_bytes()), ("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let p = pages(&doc);
|
||||
assert_eq!(p.len(), 3);
|
||||
// 4×2 px at 144 dpi → 2×1 pt; 3×2 px at 300 dpi → 0.72×0.48 pt.
|
||||
let m0 = media(&p[0]);
|
||||
assert!((m0[2] - 2.0).abs() < 0.01 && (m0[3] - 1.0).abs() < 0.01, "{m0:?}");
|
||||
let m1 = media(&p[1]);
|
||||
assert!((m1[2] - 0.72).abs() < 0.01, "{m1:?}");
|
||||
let img = |d: &Dict| {
|
||||
let x = doc.resolve(d.get(b"Resources").unwrap());
|
||||
let xo = doc.resolve(x.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
doc.resolve(xo.as_dict().unwrap().get(b"Im0").unwrap()).as_dict().cloned().unwrap()
|
||||
};
|
||||
assert!(img(&p[0]).contains(b"SMask"), "transparency is kept");
|
||||
assert!(!img(&p[2]).contains(b"SMask"));
|
||||
assert_eq!(img(&p[1]).name(b"Filter"), Some(&b"DCTDecode"[..]));
|
||||
assert_eq!(img(&p[1]).name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));
|
||||
let title = doc.resolve(doc.trailer().get(b"Info").unwrap());
|
||||
assert_eq!(title.as_dict().unwrap().get(b"Title").unwrap().as_string().unwrap().to_text(), "photo");
|
||||
assert!(matches!(from_images(&[("x.gif".into(), b"GIF89a".to_vec())]), Err(CreateError::Image(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_wrapped_and_paginated() {
|
||||
let long: String = (0..200).map(|i| format!("Line {i} of a plain text file\n")).collect();
|
||||
let doc = reopen(&from_text("notes", &format!("{long}\u{c}After a form feed"), LETTER, 11.0).unwrap());
|
||||
let p = pages(&doc);
|
||||
assert!(p.len() >= 5, "{} pages", p.len());
|
||||
let content = |d: &Dict| {
|
||||
let c = doc.resolve(d.get(b"Contents").unwrap());
|
||||
let Object::Stream(s) = &*c else { panic!() };
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
};
|
||||
assert!(content(&p[0]).contains("(Line 0 of a plain text file) Tj"));
|
||||
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
|
||||
}
|
||||
@@ -15,6 +15,7 @@ printcraft-organize = { workspace = true }
|
||||
printcraft-annot = { workspace = true }
|
||||
printcraft-forms = { workspace = true }
|
||||
printcraft-edit = { workspace = true }
|
||||
printcraft-create = { workspace = true }
|
||||
printcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
png = "0.18"
|
||||
|
||||
@@ -138,15 +138,15 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
icon: "file-plus-2",
|
||||
hue: RED,
|
||||
badge: None,
|
||||
availability: Planned("M10"),
|
||||
availability: Ready,
|
||||
sections: &[ToolSection {
|
||||
title: "Create from",
|
||||
items: &[
|
||||
item("Single file", "file-input", "create.file", Planned("M10")),
|
||||
item("Single file", "file-input", "create.file", Ready),
|
||||
item("Multiple files", "files", "create.multiple", Planned("M10")),
|
||||
item("Images", "image", "create.images", Planned("M10")),
|
||||
item("Images", "image", "create.images", Ready),
|
||||
item("Clipboard", "copy-plus", "create.clipboard", Planned("M10")),
|
||||
item("Blank page", "file-plus-2", "create.blank", Planned("M10")),
|
||||
item("Blank page", "file-plus-2", "create.blank", Ready),
|
||||
],
|
||||
}],
|
||||
},
|
||||
@@ -293,7 +293,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
sections: &[ToolSection {
|
||||
title: "Optimize",
|
||||
items: &[
|
||||
item("Reduce file size", "file-down", "optimize.reduce", Planned("M11")),
|
||||
item("Reduce file size", "file-down", "optimize.reduce", Ready),
|
||||
item("Advanced optimization", "settings-2", "optimize.advanced", Planned("M11")),
|
||||
item("Audit space usage", "gauge", "optimize.audit", Planned("M11")),
|
||||
],
|
||||
|
||||
@@ -140,6 +140,9 @@ const HELP: Option<&str> = Some("Help");
|
||||
/// Every command, in menu order.
|
||||
pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
|
||||
c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"),
|
||||
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
|
||||
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
|
||||
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
|
||||
c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"),
|
||||
c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"),
|
||||
@@ -173,6 +176,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("sign.fill.date", "Fill & Sign: date", None, None, Annotate, "clock-3"),
|
||||
c("sign.fill.signature", "Fill & Sign: sign", None, None, Annotate, "signature"),
|
||||
c("export.image", "Export to image…", FILE, None, Document, "image"),
|
||||
c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"),
|
||||
c("export.text", "Export to text…", FILE, None, Document, "type"),
|
||||
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
|
||||
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
|
||||
|
||||
@@ -881,6 +881,8 @@ pub enum EditError {
|
||||
Form(#[from] printcraft_forms::FormError),
|
||||
#[error("{0}")]
|
||||
Edit(#[from] printcraft_edit::EditError),
|
||||
#[error("{0}")]
|
||||
Create(#[from] printcraft_create::CreateError),
|
||||
#[error("the edited document could not be written: {0}")]
|
||||
Write(String),
|
||||
#[error("the edited document could not be reopened: {0}")]
|
||||
@@ -1164,6 +1166,35 @@ impl Session {
|
||||
Self::refresh(doc)
|
||||
}
|
||||
|
||||
/// A new blank document (Create ▸ Blank page).
|
||||
pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
self.write_new(&printcraft_create::blank(width, height, pages)?)
|
||||
}
|
||||
|
||||
/// A new document with one page per image (PNG, JPEG).
|
||||
pub fn create_from_images(&self, images: &[(String, Vec<u8>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
self.write_new(&printcraft_create::from_images(images)?)
|
||||
}
|
||||
|
||||
/// A new document from plain text (US Letter, 11 pt Helvetica).
|
||||
pub fn create_from_text(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
self.write_new(&printcraft_create::from_text(title, text, printcraft_create::LETTER, 11.0)?)
|
||||
}
|
||||
|
||||
/// Reduce File Size: identical resources merged, unused objects dropped, objects packed
|
||||
/// into compressed object streams. Returns the bytes and how many objects were merged.
|
||||
/// The open document is not changed (Acrobat saves the reduced copy as a new file).
|
||||
pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc<Vec<u8>>, usize), EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let mut cos = editor.cos.clone();
|
||||
let all: Vec<printcraft_cos::ObjRef> = cos.object_numbers().into_iter().map(|n| printcraft_cos::ObjRef::new(n, cos.generation(n))).collect();
|
||||
let merged = printcraft_organize::dedupe_resources(&mut cos, &all, false);
|
||||
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
Ok((Arc::new(bytes), merged))
|
||||
}
|
||||
|
||||
/// Combine whole files, in order, into new PDF bytes (one bookmark per file).
|
||||
pub fn combine(&self, sources: &[(String, Arc<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
@@ -717,3 +717,24 @@ fn export_images_and_text_follow_the_working_file() {
|
||||
assert_eq!(ex.text_of(&[0, 1]).unwrap(), "Page 1\n\u{c}Page 2\n");
|
||||
assert!(ex.png(5, 72.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_reduce() {
|
||||
let mut s = Session::new();
|
||||
let blank = s.create_blank(612.0, 792.0, 2).unwrap();
|
||||
let id = s.open_new("Untitled.pdf", blank).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
|
||||
let text = s.create_from_text("notes", "hello\nworld").unwrap();
|
||||
let t = s.open_new("notes.pdf", text).unwrap();
|
||||
assert_eq!(page_texts(&s, t), ["hello\nworld"]);
|
||||
// A file with the same page imported twice shares nothing until reduced.
|
||||
let (mut s2, id2) = session_with(1);
|
||||
let src = Arc::new(fixture(1));
|
||||
s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap();
|
||||
let full = s2.save_full_bytes(id2).unwrap();
|
||||
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
|
||||
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());
|
||||
let mut s3 = Session::new();
|
||||
let r = s3.open("r.pdf", None, reduced, None).unwrap();
|
||||
assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]);
|
||||
}
|
||||
|
||||
@@ -799,6 +799,15 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
|
||||
let out = scroll.show_viewport(ui, |ui, viewport| {
|
||||
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
|
||||
// The Hand tool pans: the content widget takes every drag, so scroll by its delta.
|
||||
if hand {
|
||||
if resp.dragged() {
|
||||
ui.scroll_with_delta(resp.drag_delta());
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
let origin = resp_rect.min - vec2(0.0, y_shift);
|
||||
let painter = ui.painter();
|
||||
let visible = viewport.translate(resp_rect.min.to_vec2());
|
||||
|
||||
@@ -203,6 +203,10 @@ impl PrintCraftApp {
|
||||
self.dialog = Some(Dialog::Signature);
|
||||
}
|
||||
}
|
||||
"create.blank" => self.create_blank(),
|
||||
"create.file" => self.open_dialog(),
|
||||
"create.images" => self.create_from_images_dialog(),
|
||||
"optimize.reduce" => self.reduce_file_size(),
|
||||
"page.duplicate" => {
|
||||
self.apply_edit(Edit::DuplicatePages { pages: targets });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Create a PDF (blank, from images, from text) and Reduce File Size (execution plan M10.2,
|
||||
//! M11.1). Opening an image or a text file converts it to a new, unsaved PDF, as Acrobat does.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::PrintCraftApp;
|
||||
|
||||
/// File types Open accepts besides PDF (converted on open).
|
||||
pub const CONVERTIBLE: [&str; 4] = ["png", "jpg", "jpeg", "txt"];
|
||||
|
||||
fn is_image(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0xFF, 0xD8]) || bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|
||||
}
|
||||
|
||||
fn stem(name: &str) -> &str {
|
||||
name.rsplit_once('.').map_or(name, |(s, _)| s)
|
||||
}
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Convert a non-PDF file (image, text) into a new tab. Returns `None` when `bytes` is not
|
||||
/// something Create understands (the caller then tries to open it as a PDF).
|
||||
pub(crate) fn open_converted(&mut self, name: &str, bytes: &[u8]) -> Option<Result<(), String>> {
|
||||
let head = &bytes[..bytes.len().min(1024)];
|
||||
if head.windows(5).any(|w| w == b"%PDF-") {
|
||||
return None;
|
||||
}
|
||||
let created = if is_image(bytes) {
|
||||
self.session.create_from_images(&[(name.to_string(), bytes.to_vec())])
|
||||
} else if name.to_ascii_lowercase().ends_with(".txt") {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
self.session.create_from_text(stem(name), &text)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(self.open_created_bytes(&format!("{}.pdf", stem(name)), created.map_err(|e| e.to_string())))
|
||||
}
|
||||
|
||||
fn open_created_bytes(&mut self, name: &str, created: Result<Arc<Vec<u8>>, String>) -> Result<(), String> {
|
||||
let bytes = created?;
|
||||
let id = self.session.open_new(name, bytes).map_err(|e| e.to_string())?;
|
||||
let info = &self.session.get(id).expect("just opened").info;
|
||||
self.views.push(crate::DocView::new(id, info));
|
||||
self.active = Some(self.views.len() - 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create ▸ Blank page: a new untitled US Letter document.
|
||||
pub(crate) fn create_blank(&mut self) {
|
||||
let created = self.session.create_blank(612.0, 792.0, 1).map_err(|e| e.to_string());
|
||||
if let Err(e) = self.open_created_bytes("Untitled.pdf", created) {
|
||||
self.notify(format!("Couldn't create a PDF: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Create ▸ Images: several images, one page each, in one new document.
|
||||
pub(crate) fn create_from_images_dialog(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(files) = rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg"]).set_title("Choose images").pick_files() else {
|
||||
return;
|
||||
};
|
||||
let mut images = Vec::new();
|
||||
for f in files {
|
||||
match std::fs::read(&f) {
|
||||
Ok(b) => images.push((f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), b)),
|
||||
Err(e) => {
|
||||
self.notify(format!("Couldn't read {}: {e}", f.display()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.create_from_images(images);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify("On the web, open or drop an image to convert it");
|
||||
}
|
||||
|
||||
/// One new document from images (tests and automation call this directly).
|
||||
pub fn create_from_images(&mut self, images: Vec<(String, Vec<u8>)>) {
|
||||
if images.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = if images.len() == 1 { format!("{}.pdf", stem(&images[0].0)) } else { "Images.pdf".to_string() };
|
||||
let created = self.session.create_from_images(&images).map_err(|e| e.to_string());
|
||||
if let Err(e) = self.open_created_bytes(&name, created) {
|
||||
self.notify(format!("Couldn't create a PDF: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce File Size: write a compacted copy (the open document is unchanged).
|
||||
pub(crate) fn reduce_file_size(&mut self) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let (before, name) = (doc.bytes.len(), format!("{} (reduced).pdf", stem(&doc.name)));
|
||||
let (bytes, _) = match self.session.reduced_bytes(id) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.notify(format!("Couldn't reduce the file: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let saved = |app: &mut PrintCraftApp, place: String| {
|
||||
let pct = 100.0 * (1.0 - bytes.len() as f64 / before.max(1) as f64);
|
||||
app.notify(format!(
|
||||
"Saved {place}: {} → {} ({pct:.0}% smaller)",
|
||||
crate::panels::human_size(before),
|
||||
crate::panels::human_size(bytes.len())
|
||||
));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = match &self.save_override {
|
||||
Some(p) => Some(p.clone()),
|
||||
None => rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name).save_file().map(|p| p.to_string_lossy().into_owned()),
|
||||
};
|
||||
let Some(path) = path else { return };
|
||||
match crate::editing::write_atomically(&path, &bytes) {
|
||||
Ok(()) => saved(self, path),
|
||||
Err(e) => self.notify(format!("Couldn't write {path}: {e}")),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
match crate::editing::download(&name, &bytes) {
|
||||
Ok(()) => saved(self, name),
|
||||
Err(e) => self.notify(format!("Couldn't download {name}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod commands;
|
||||
pub mod comments;
|
||||
mod comments_panel;
|
||||
pub mod control;
|
||||
mod create_ui;
|
||||
mod crop;
|
||||
mod export_ui;
|
||||
mod marks_ui;
|
||||
@@ -277,6 +278,10 @@ impl PrintCraftApp {
|
||||
|
||||
/// Open a document and make it the active tab. Encrypted files raise the password prompt.
|
||||
pub fn open_bytes(&mut self, name: &str, path: Option<String>, bytes: Vec<u8>) -> Result<(), String> {
|
||||
// Images and text files become new, unsaved PDFs (Create a PDF).
|
||||
if let Some(r) = self.open_converted(name, &bytes) {
|
||||
return r;
|
||||
}
|
||||
self.try_open(name, path, std::sync::Arc::new(bytes), None)
|
||||
}
|
||||
|
||||
@@ -412,7 +417,9 @@ impl PrintCraftApp {
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(p) = rfd::FileDialog::new().add_filter("PDF", &["pdf"]).pick_file() {
|
||||
if let Some(p) =
|
||||
rfd::FileDialog::new().add_filter("PDF", &["pdf"]).add_filter("Images and text (converted to PDF)", &create_ui::CONVERTIBLE).pick_file()
|
||||
{
|
||||
self.open_path(&p.to_string_lossy());
|
||||
}
|
||||
// Browsers pick files asynchronously; the bytes arrive through `inbox`.
|
||||
@@ -420,7 +427,9 @@ impl PrintCraftApp {
|
||||
{
|
||||
let inbox = self.inbox.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Some(h) = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).pick_file().await {
|
||||
if let Some(h) =
|
||||
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter("Images and text", &create_ui::CONVERTIBLE).pick_file().await
|
||||
{
|
||||
let bytes = h.read().await;
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((h.file_name(), bytes));
|
||||
|
||||
@@ -43,7 +43,7 @@ fn harness() -> Harness<'static, PrintCraftApp> {
|
||||
}
|
||||
|
||||
/// Commands that open a native file picker can't run headless.
|
||||
const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as"];
|
||||
const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images"];
|
||||
|
||||
#[test]
|
||||
fn every_registered_command_is_implemented() {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Create a PDF and Reduce File Size in the real shell.
|
||||
|
||||
use printcraft_ui_egui::PrintCraftApp;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, 8, 4);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut w = enc.write_header().unwrap();
|
||||
w.write_image_data(&[120u8; 8 * 4 * 3]).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_images_and_text_converts_them_to_new_pdfs() {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("photo.png", Some("/tmp/photo.png".into()), png()).unwrap();
|
||||
app.open_bytes("notes.txt", None, b"first line\nsecond line".to_vec()).unwrap();
|
||||
app.create_from_images(vec![("a.png".into(), png()), ("b.png".into(), png())]);
|
||||
assert!(app.execute("create.blank"));
|
||||
let docs = app.session.docs();
|
||||
let summary: Vec<(String, usize, bool)> = docs.iter().map(|d| (d.name.clone(), d.info.pages.len(), d.dirty)).collect();
|
||||
assert_eq!(
|
||||
summary,
|
||||
[("photo.pdf".to_string(), 1, true), ("notes.pdf".into(), 1, true), ("Images.pdf".into(), 2, true), ("Untitled.pdf".into(), 1, true)]
|
||||
);
|
||||
assert!(docs[0].path.is_none(), "a converted file has no PDF path yet: Save asks where");
|
||||
assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_file_size_writes_a_compact_copy() {
|
||||
let dir = std::env::temp_dir().join(format!("printcraft-reduce-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("reduced.pdf");
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("notes.txt", None, "lorem ipsum ".repeat(500).into_bytes()).unwrap();
|
||||
app.save_override = Some(out.to_string_lossy().into_owned());
|
||||
assert!(app.execute("optimize.reduce"));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
assert!(app.session.docs()[0].dirty, "the open document is unchanged");
|
||||
}
|
||||
@@ -180,3 +180,24 @@ fn fonts_tab_lists_fonts_and_embedding() {
|
||||
h.get_by_label("Helvetica");
|
||||
h.get_by_label_contains("Not embedded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hand_tool_pans_by_dragging() {
|
||||
let mut h = harness(&[("layout", "continuous"), ("zoom", "150"), ("quick", "hand")]);
|
||||
h.run_steps(4);
|
||||
let before = rect(&h, 0).expect("page 1 on screen");
|
||||
let start = egui::pos2(700.0, 700.0);
|
||||
h.hover_at(start);
|
||||
h.run_steps(1);
|
||||
h.drag_at(start);
|
||||
h.run_steps(1);
|
||||
for k in 1..=5 {
|
||||
h.hover_at(start - egui::vec2(0.0, 60.0 * k as f32));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(start - egui::vec2(0.0, 300.0));
|
||||
h.run_steps(4);
|
||||
let after = rect(&h, 0).expect("still on screen");
|
||||
assert!(before.top() - after.top() > 200.0, "dragging up scrolls down: {before:?} → {after:?}");
|
||||
assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand");
|
||||
}
|
||||
|
||||
@@ -1031,11 +1031,11 @@ title = "Hand tool for panning"
|
||||
area = "B"
|
||||
tier = "P0"
|
||||
milestone = "M3"
|
||||
status = "planned"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging"]
|
||||
notes = "drag to pan; Grab/Grabbing cursors"
|
||||
|
||||
[[feature]]
|
||||
id = "view.rotate-view"
|
||||
@@ -6643,11 +6643,11 @@ title = "Create PDF from images (JPEG, PNG, TIFF, GIF, BMP, JPEG 2000)"
|
||||
area = "J"
|
||||
tier = "P0"
|
||||
milestone = "M10"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["create.images", "create.file"]
|
||||
tools = ["doc_create"]
|
||||
evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::images_become_pages_at_their_resolution"]
|
||||
notes = "PNG and JPEG (grey, RGB, CMYK; transparency kept); TIFF, GIF, BMP, JPEG 2000 not yet"
|
||||
|
||||
[[feature]]
|
||||
id = "create.from-multipage-tiff"
|
||||
@@ -6667,11 +6667,11 @@ title = "Create PDF from plain text"
|
||||
area = "J"
|
||||
tier = "P0"
|
||||
milestone = "M10"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["create.file"]
|
||||
tools = ["doc_create"]
|
||||
evidence = ["crates/create/src/tests.rs::text_is_wrapped_and_paginated", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
|
||||
notes = "Helvetica 11 pt on US Letter; form feeds start pages"
|
||||
|
||||
[[feature]]
|
||||
id = "create.from-markdown"
|
||||
@@ -6715,10 +6715,10 @@ title = "Create a blank PDF"
|
||||
area = "J"
|
||||
tier = "P0"
|
||||
milestone = "M10"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
status = "shipped"
|
||||
commands = ["create.blank"]
|
||||
tools = ["doc_create"]
|
||||
evidence = ["crates/ui-egui/tests/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
|
||||
notes = ""
|
||||
|
||||
[[feature]]
|
||||
@@ -7067,11 +7067,11 @@ title = "Reduce file size (one click)"
|
||||
area = "K"
|
||||
tier = "P0"
|
||||
milestone = "M11"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["optimize.reduce"]
|
||||
tools = ["doc_reduce"]
|
||||
evidence = ["crates/ui-egui/tests/create.rs::reduce_file_size_writes_a_compact_copy", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/automation.rs::creating_and_reducing_through_tools"]
|
||||
notes = "merges identical resources, drops unused objects, object streams; no image downsampling yet"
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.compatibility-level"
|
||||
|
||||
Reference in New Issue
Block a user