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:
Brandon Thomas
2026-10-01 23:29:17 -04:00
co-authored by Claude Opus 5.5
parent 2c69c02699
commit adb644c5e1
22 changed files with 817 additions and 30 deletions
Generated
+12
View File
@@ -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",
+1
View File
@@ -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" }
+49
View File
@@ -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)?;
+21
View File
@@ -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.")
+15
View File
@@ -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(_))));
}
+18
View File
@@ -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 }
+12
View File
@@ -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.
+286
View File
@@ -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;
+99
View File
@@ -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");
}
+1
View File
@@ -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"
+5 -5
View File
@@ -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")),
],
+4
View File
@@ -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"),
+31
View File
@@ -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<_>, _>>()?;
+21
View File
@@ -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"]);
}
+9
View File
@@ -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());
+4
View File
@@ -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 });
}
+128
View File
@@ -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 -2
View File
@@ -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));
+1 -1
View File
@@ -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() {
+46
View File
@@ -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");
}
+21
View File
@@ -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");
}
+22 -22
View File
@@ -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"