Merge PR #3227: M11: target PDF version and object streams option in PDF Optimizer

Conflicts: docs/roadmap.md: all status lines kept, M10 row from main, M11 row with #3227's two sentences. parity/acrobat-features.toml: save-with-object-streams entry from #3227 with its test paths under tests/it/.
This commit is contained in:
aiongg
2026-10-24 06:25:55 +00:00
13 changed files with 290 additions and 17 deletions
+7
View File
@@ -703,8 +703,15 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation.
- **2026-10-18 (PR #3227 integration):** M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged.
- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
+21 -1
View File
@@ -1215,7 +1215,7 @@ impl Automation {
}
fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset};
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset};
let id = self.doc(a)?.id;
let before = self.doc(a)?.bytes.len();
let path = self.resolve(a.str("path")?, true)?;
@@ -1223,6 +1223,10 @@ impl Automation {
None => Preset::Standard.settings(),
Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(),
};
if let Some(c) = a.opt_str("compatibility")? {
settings.compatibility = Compatibility::from_name(c)
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?;
}
let images = |key: &str, base: ImageSettings| -> Result<ImageSettings> {
let Some(v) = a.get(key) else { return Ok(base) };
let mut s = base;
@@ -1256,6 +1260,7 @@ impl Automation {
("discard_tags", &mut settings.discard_tags),
("discard_print_settings", &mut settings.discard_print_settings),
("flate_unencoded", &mut settings.flate_unencoded),
("object_streams", &mut settings.object_streams),
("remove_invalid_links", &mut settings.remove_invalid_links),
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
] {
@@ -1278,9 +1283,24 @@ impl Automation {
};
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
write_atomic(&path, &bytes)?;
fn valid_version(v: &[u8]) -> Option<&str> {
if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None }
}
let catalog_version = (settings.compatibility == Compatibility::Retain)
.then(|| {
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
let root = doc.root()?;
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
})
.flatten();
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
let pdf_version = catalog_version.as_deref().or(header_version);
let o = &r.optimize;
Ok(json!({
"path": path.to_string_lossy(),
"pdf_version": pdf_version,
"object_streams": settings.effective_object_streams(),
"bytes_before": before,
"bytes_after": bytes.len(),
"merged_images": o.merged_images,
+3 -1
View File
@@ -1375,13 +1375,14 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
"doc": doc(),
"path": { "type": "string" },
"preset": { "type": "string", "enum": ["standard", "mobile"] },
"compatibility": { "type": "string", "enum": ["retain", "1.4", "1.5", "1.6", "1.7", "2.0"] },
"merge_fragments": { "type": "boolean" },
"color": { "type": "object" },
"gray": { "type": "object" },
@@ -1390,6 +1391,7 @@ pub fn tools() -> Vec<ToolDef> {
"discard_tags": { "type": "boolean" },
"discard_print_settings": { "type": "boolean" },
"flate_unencoded": { "type": "boolean" },
"object_streams": { "type": "boolean" },
"remove_invalid_links": { "type": "boolean" },
"remove_unreferenced_dests": { "type": "boolean" },
"discard": { "type": "array", "items": { "type": "string" } },
+22
View File
@@ -5755,6 +5755,28 @@ fn optimizing_through_tools() {
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
assert_eq!(reduced["written"], true);
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap());
// Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off).
let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" }));
assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}");
let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap();
assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm"));
let v20 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v20.pdf", "compatibility": "2.0", "object_streams": false }));
assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}");
assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n"));
// In retain mode, a catalog /Version override is reported as the effective PDF version.
let mut cos14 = pdfcraft_cos::Document::open(std::sync::Arc::new(v14_bytes)).unwrap();
let root = cos14.root().unwrap();
cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.6"))).unwrap();
let cat16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
std::fs::write(dir.join("cat16.pdf"), &cat16).unwrap();
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(
a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })),
Err(ToolError::InvalidArgs(_))
+5
View File
@@ -897,6 +897,11 @@ impl Document {
&self.version
}
/// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save.
pub fn set_version(&mut self, version: impl Into<String>) {
self.version = version.into();
}
pub fn trailer(&self) -> &Dict {
&self.trailer
}
+5 -1
View File
@@ -68,7 +68,11 @@ impl Session {
cos: editor.cos.clone(),
settings: settings.clone(),
discard: discard.to_vec(),
opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() },
opts: SaveOptions {
mod_date: self.now().map(pdfcraft_cos::pdf_date),
object_streams: settings.effective_object_streams(),
..SaveOptions::default()
},
})
}
}
+9
View File
@@ -39,3 +39,12 @@ then this, then merges identical objects and writes a full, compressed save
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
progress bar). JPEG decoding and
encoding and resampling come from the `image` crate.
## Target PDF version: partial compatibility
The target selects the header version, clears the catalog `/Version`, controls object
streams (disabled for PDF 1.4), and checks encryption requirements. It does **not**
detect or convert content newer than the target: optional content, JPEG 2000 images,
16-bit images, OpenType fonts and PDF 2.0 features can remain. A PDF 1.4 header
therefore does not guarantee compatibility with PDF 1.4 readers or validators.
`optimize.compatibility-level` remains partial until content compatibility is checked.
+96
View File
@@ -38,12 +38,62 @@ pub use images::effective_resolutions;
pub enum OptimizeError {
#[error("the document has no page tree")]
NoPages,
#[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")]
IncompatibleEncryption { required: &'static str, chosen: &'static str },
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
#[error("the optimization was cancelled")]
Cancelled,
}
/// The target header version and object-stream policy for an optimized copy.
///
/// Partial compatibility only: encryption requirements are checked, but content newer than
/// the target is neither detected nor converted. A lower header does not guarantee that
/// older readers can read the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compatibility {
/// Keep the document's version (upgraded to 1.5 when object streams are written).
#[default]
Retain,
Pdf1_4,
Pdf1_5,
Pdf1_6,
Pdf1_7,
Pdf2_0,
}
impl Compatibility {
pub const ALL: [Compatibility; 6] =
[Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0];
/// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`).
pub fn name(self) -> &'static str {
match self {
Compatibility::Retain => "retain",
Compatibility::Pdf1_4 => "1.4",
Compatibility::Pdf1_5 => "1.5",
Compatibility::Pdf1_6 => "1.6",
Compatibility::Pdf1_7 => "1.7",
Compatibility::Pdf2_0 => "2.0",
}
}
pub fn from_name(name: &str) -> Option<Compatibility> {
let trimmed = name.trim();
let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed };
Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped))
}
/// The three-character `%PDF-` header version, when a target version is chosen.
pub fn version(self) -> Option<&'static str> {
match self {
Compatibility::Retain => None,
_ => Some(self.name()),
}
}
}
/// Where an optimization is, reported to [`optimize_with_progress`]'s callback.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
@@ -89,6 +139,8 @@ impl ImageSettings {
#[derive(Clone, Debug, PartialEq)]
pub struct Settings {
/// Target PDF version (`Retain` by default).
pub compatibility: Compatibility,
/// Put page pictures drawn as many stacked fragments back together first ([`fragments`]).
pub merge_fragments: bool,
pub color: ImageSettings,
@@ -100,16 +152,28 @@ pub struct Settings {
/// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or
/// ASCIIHex layer from streams that have one.
pub flate_unencoded: bool,
/// Clean Up: pack non-stream objects into compressed object streams with a cross-reference
/// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`].
pub object_streams: bool,
/// Clean Up: remove links (and bookmark destinations) that point nowhere.
pub remove_invalid_links: bool,
/// Clean Up: remove named destinations nothing refers to.
pub remove_unreferenced_dests: bool,
}
impl Settings {
/// Whether `write_full` should pack non-stream objects into compressed object streams:
/// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7).
pub fn effective_object_streams(&self) -> bool {
self.object_streams && self.compatibility != Compatibility::Pdf1_4
}
}
impl Default for Settings {
/// Acrobat's Reduce File Size choices.
fn default() -> Self {
Self {
compatibility: Compatibility::Retain,
merge_fragments: true,
color: ImageSettings::REDUCE,
gray: ImageSettings::REDUCE,
@@ -118,6 +182,7 @@ impl Default for Settings {
discard_tags: false,
discard_print_settings: false,
flate_unencoded: true,
object_streams: true,
remove_invalid_links: true,
remove_unreferenced_dests: true,
}
@@ -186,6 +251,20 @@ pub struct Report {
pub unused_xobjects: usize,
}
/// The minimum PDF version required by `doc`'s active security handler, when it uses features
/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7).
fn required_encryption_version(doc: &Document) -> Option<&'static str> {
let h = doc.output_handler()?;
let d = h.dict();
if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) {
Some("1.7")
} else if d.v == 4 {
if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") }
} else {
None
}
}
/// Optimize `doc` in place.
pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, OptimizeError> {
optimize_with_progress(doc, settings, &mut |_| true)
@@ -195,6 +274,13 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, Optim
/// during a long one). Returning `false` stops with [`OptimizeError::Cancelled`]; `doc` is then
/// partly optimized and should be dropped.
pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result<Report, OptimizeError> {
// Lexical ordering is valid for these fixed versions with single-digit components.
if let Some(chosen) = settings.compatibility.version()
&& let Some(required) = required_encryption_version(doc)
&& chosen < required
{
return Err(OptimizeError::IncompatibleEncryption { required, chosen });
}
let mut report = Report::default();
let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?;
if settings.merge_fragments {
@@ -230,6 +316,16 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
}
}
let root = doc.root();
if let Some(chosen) = settings.compatibility.version() {
doc.set_version(chosen);
if let Some(root) = root
&& doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version"))
{
doc.update_dict(root, |c| {
c.remove(b"Version");
})?;
}
}
if settings.discard_tags
&& let Some(root) = root
&& doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot"))
+74 -1
View File
@@ -3,7 +3,9 @@
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize};
use pdfcraft_optimize::{
Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize,
};
fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option<ObjRef>) -> ObjRef {
// A smooth gradient with a little texture (photo-like).
@@ -1104,6 +1106,77 @@ fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() {
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
}
#[test]
fn compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() {
let mut base = Document::new_empty();
page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET");
let root = base.root().unwrap();
base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap();
for (compat, expected) in [
(Compatibility::Pdf1_4, "1.4"),
(Compatibility::Pdf1_5, "1.5"),
(Compatibility::Pdf1_6, "1.6"),
(Compatibility::Pdf1_7, "1.7"),
(Compatibility::Pdf2_0, "2.0"),
] {
let mut doc = base.clone();
let settings = Settings { compatibility: compat, ..Settings::default() };
optimize(&mut doc, &settings).unwrap();
assert_eq!(doc.version(), expected);
assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared");
let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap();
assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}");
let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm");
assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}");
let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap();
assert_eq!(info.pdf_version, expected);
}
// Disabling object_streams on PDF 1.7 writes a classic cross-reference table.
let mut doc = base.clone();
let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() };
optimize(&mut doc, &settings).unwrap();
let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap();
assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm"));
// Encryption that requires a newer PDF version than the chosen compatibility is refused.
let mut aes256 = base.clone();
aes256
.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "u",
owner_password: "o",
permissions: -1,
encrypt_metadata: true,
seed: [1; 32],
})
.unwrap();
assert!(matches!(
optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }),
Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" })
));
optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap();
assert_eq!(aes256.version(), "2.0");
let mut aes128 = base;
aes128
.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes128,
user_password: "u",
owner_password: "o",
permissions: -1,
encrypt_metadata: true,
seed: [1; 32],
})
.unwrap();
assert!(matches!(
optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }),
Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" })
));
optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap();
}
#[test]
fn image_resolution_keeps_comments_across_content_pieces() {
for ending in ["", "\n", "\r", "\r\n"] {
+22 -2
View File
@@ -8,7 +8,7 @@
use std::sync::{Arc, Mutex};
use egui::{Align, Layout};
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings};
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings};
use pdfcraft_engine::optimizer::{OptimizeStage, Optimized};
use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport};
@@ -172,6 +172,17 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
}
}
});
ui.add_space(8.0);
ui.label(tl!("Compatibility"));
let compat_label = |c: Compatibility| match c {
Compatibility::Retain => tl!("Retain existing").to_string(),
_ => format!("PDF {}", c.name()),
};
egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| {
for c in Compatibility::ALL {
ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c));
}
});
});
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
@@ -221,7 +232,16 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded")));
ui.add(widgets::checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks")));
ui.add(widgets::checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations")));
ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Compress document structure (object streams)")));
let mut object_streams = s.effective_object_streams();
if ui
.add_enabled(
s.compatibility != Compatibility::Pdf1_4,
widgets::checkbox(&mut object_streams, tl!("Compress document structure (object streams)")),
)
.changed()
{
s.object_streams = object_streams;
}
ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Remove unused objects and merge identical ones")));
}
}
+13
View File
@@ -239,6 +239,19 @@ fn the_pdf_optimizer_dialog_picks_a_preset() {
h.run_steps(2);
assert!(!h.state().optimize_draft.settings.merge_fragments);
h.get_by_value("Custom");
// Pick a target PDF version and toggle structure compression on Clean Up.
h.get_by_value("Retain existing").click();
h.run_steps(4);
h.get_by_label("PDF 2.0").click();
h.run_steps(2);
assert_eq!(h.state().optimize_draft.settings.compatibility, pdfcraft_engine::optimize::Compatibility::Pdf2_0);
h.get_by_label("Clean Up").click();
h.run_steps(2);
assert!(h.state().optimize_draft.settings.object_streams);
h.get_by_label("Compress document structure (object streams)").click();
h.run_steps(2);
assert!(!h.state().optimize_draft.settings.object_streams);
}
fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec<u8>)> {
+3 -1
View File
@@ -6,6 +6,7 @@
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** patch (PR #3227 integration; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
@@ -191,7 +192,7 @@ total matches the parity estimate.
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA PR #3227 integration retains existing scope and estimates against upstream `80b7d215`. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
@@ -345,3 +346,4 @@ Estimates unchanged.
| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. |
| 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones |
| 2026-10-18 | minor | #3227: M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged. |
+10 -10
View File
@@ -7274,11 +7274,11 @@ title = "Make compatible with a chosen PDF version"
area = "K"
tier = "P1"
milestone = "M11"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["optimize.advanced"]
tools = ["doc_optimize"]
evidence = ["crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset"]
notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090). Partial compatibility only: content newer than the target is neither detected nor converted (including optional content, JPX images, 16-bit images, OpenType fonts and PDF 2.0 features); the chosen header does not guarantee compatibility with older readers"
[[feature]]
id = "optimize.optimizer"
@@ -7574,11 +7574,11 @@ title = "Compress document structure (object streams)"
area = "K"
tier = "P1"
milestone = "M11"
status = "partial"
commands = ["file.save_as"]
tools = ["doc_save"]
evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams"]
notes = "on by default for full saves; no Optimizer option yet"
status = "shipped"
commands = ["file.save_as", "optimize.advanced"]
tools = ["doc_save", "doc_optimize"]
evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams", "crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset"]
notes = "on by default for full saves and in PDF Optimizer ▸ Clean Up (and doc_optimize object_streams); turning it off or targeting PDF 1.4 unpacks object streams into standalone objects with a classic cross-reference table"
[[feature]]
id = "optimize.flate-unencoded"