mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 01:11:19 +00:00
Merge storytold/main into feat/model3d-render
# Conflicts: # crates/automation/src/lib.rs # crates/automation/tests/it/main.rs
This commit is contained in:
commit
697a02e2ef
124 files changed
+9802
-1692
No files matched your search
@@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
|
||||
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
|
||||
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
|
||||
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
|
||||
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
|
||||
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
|
||||
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
|
||||
|
||||
@@ -223,12 +223,16 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
|
||||
|
||||
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
|
||||
|
||||
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
@@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
|
||||
Some(res)
|
||||
}
|
||||
|
||||
fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
pub(crate) fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
@@ -288,6 +288,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
Stream::flate(d, content)
|
||||
}
|
||||
|
||||
/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose
|
||||
/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing).
|
||||
pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option<String> {
|
||||
let [x0, y0, x1, y1] = at;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if image {
|
||||
return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)));
|
||||
}
|
||||
let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?;
|
||||
Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0)))
|
||||
}
|
||||
|
||||
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
|
||||
/// when the signature is added to a turned page): 0, 90, 180 or 270.
|
||||
pub(crate) fn picture_turn(d: &Dict) -> i64 {
|
||||
@@ -325,7 +337,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
}
|
||||
|
||||
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
|
||||
fn helvetica_resource() -> Object {
|
||||
pub(crate) fn helvetica_resource() -> Object {
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
@@ -786,15 +798,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
|
||||
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
|
||||
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
|
||||
let place = if image {
|
||||
format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
|
||||
} else {
|
||||
let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
|
||||
format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
|
||||
};
|
||||
let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() {
|
||||
[w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
|
||||
_ => None,
|
||||
});
|
||||
let place = picture_place(rect, image, size)?;
|
||||
c.push_str(&format!("q {place} /Pic Do Q\n"));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
||||
|
||||
@@ -24,6 +24,7 @@ mod info;
|
||||
mod lookup;
|
||||
mod model3d;
|
||||
mod modify;
|
||||
mod threed;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -43,6 +44,7 @@ pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
||||
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
||||
@@ -72,6 +74,12 @@ pub enum AnnotError {
|
||||
/// with a font that has those characters. The message names the character.
|
||||
#[error("{0}")]
|
||||
Undrawable(String),
|
||||
/// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`).
|
||||
#[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")]
|
||||
NotAModel,
|
||||
/// A 3D model larger than `MAX_MODEL_BYTES`.
|
||||
#[error("the 3D model is {size} bytes; the largest accepted is {max}")]
|
||||
ModelTooLarge { size: usize, max: usize },
|
||||
#[error("{0}")]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -2251,6 +2251,141 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's
|
||||
/// catalog says so, and a newer one is left as it is.
|
||||
#[test]
|
||||
fn a_3d_model_raises_an_older_document_to_its_version() {
|
||||
let catalog_version = |doc: &Document| {
|
||||
let root = doc.root().unwrap();
|
||||
doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec)
|
||||
};
|
||||
let add = |doc: &mut Document, model: &[u8]| {
|
||||
let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
|
||||
add_3d_model(doc, &new, &meta("m")).unwrap();
|
||||
};
|
||||
let mut doc = fixture();
|
||||
add(&mut doc, b"U3D\0rest");
|
||||
assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D");
|
||||
for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] {
|
||||
let mut doc = fixture();
|
||||
doc.set_version("1.4");
|
||||
add(&mut doc, model);
|
||||
assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version));
|
||||
}
|
||||
let mut doc = fixture();
|
||||
doc.set_version("2.0");
|
||||
add(&mut doc, b"PRC rest");
|
||||
assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is");
|
||||
}
|
||||
|
||||
/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping
|
||||
/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing.
|
||||
#[test]
|
||||
fn a_prc_model_declares_the_adobe_extension_before_pdf_2() {
|
||||
let adbe = |doc: &Document| {
|
||||
let root = doc.root().unwrap();
|
||||
let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone());
|
||||
ext.map(|e| {
|
||||
let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone());
|
||||
(e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int))))
|
||||
})
|
||||
};
|
||||
let add = |doc: &mut Document, model: &[u8]| {
|
||||
let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
|
||||
add_3d_model(doc, &new, &meta("m")).unwrap();
|
||||
};
|
||||
let mut doc = fixture();
|
||||
add(&mut doc, b"PRC rest");
|
||||
assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1))))));
|
||||
let mut doc = fixture();
|
||||
add(&mut doc, b"U3D | ||||