mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
Merge remote-tracking branch 'origin/main' into w
# Conflicts: # vendor/README.md
This commit is contained in:
commit
f3dc867651
188 files changed
+21859
-1820
No files matched your search
@@ -42,3 +42,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
+3
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (255)
|
||||
## In this repository (257)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -203,6 +203,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
|
||||
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
|
||||
| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. |
|
||||
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
@@ -262,6 +263,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
@@ -2156,6 +2156,17 @@ adobe_data = true
|
||||
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
|
||||
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/prc/src/acof/table.rs"
|
||||
title = "PRC Double prefix-code table (as a Rust table)"
|
||||
author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)"
|
||||
source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header."
|
||||
licence = "MIT"
|
||||
licence_file = "licenses/prc-rs-LICENSE.txt"
|
||||
kind = "data"
|
||||
usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data."
|
||||
sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.37.0"
|
||||
@@ -3189,6 +3200,17 @@ kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/a-large-small.svg"
|
||||
title = "Lucide icon \"a-large-small\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
|
||||
licence = "ISC AND MIT"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
|
||||
sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/move-horizontal.svg"
|
||||
title = "Lucide icon \"move-horizontal\""
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+20
@@ -4277,6 +4277,8 @@ dependencies = [
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-organize",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-prc",
|
||||
"pdfcraft-prc-render",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
@@ -4551,6 +4553,24 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-prc"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-prc-render"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-prc",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.18.1"
|
||||
|
||||
@@ -63,6 +63,8 @@ pdfcraft-engine = { path = "crates/engine", version = "0.18.1" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.18.1" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false }
|
||||
pdfcraft-prc = { path = "crates/prc", version = "0.18.1" }
|
||||
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -88,6 +88,13 @@ destination = "licenses/egui-emoji-icon-font-mit-license.txt"
|
||||
sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1"
|
||||
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt"
|
||||
|
||||
[[document]]
|
||||
source = "licenses/prc-rs-LICENSE.txt"
|
||||
licences = ["MIT"]
|
||||
destination = "licenses/prc-rs-LICENSE.txt"
|
||||
sha256 = "ddb846513d92153d8fa16b18ed3e7c86953a582f0d2f49f02b29f1bd569dac58"
|
||||
origin = "https://raw.githubusercontent.com/ralovich/prc-rs/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/LICENSE"
|
||||
|
||||
[[document]]
|
||||
source = "vendor/hayro-interpret/LICENSE-APACHE"
|
||||
licences = ["Apache-2.0"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-a-large-small"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
|
||||
<path d="M15.697 14h5.606" />
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -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));
|
||||
|
||||
@@ -22,7 +22,9 @@ mod create;
|
||||
mod draw;
|
||||
mod info;
|
||||
mod lookup;
|
||||
mod model3d;
|
||||
mod modify;
|
||||
mod threed;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -36,11 +38,13 @@ use lookup::{annots, page_ref};
|
||||
pub use lookup::{
|
||||
has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
|
||||
};
|
||||
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
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,
|
||||
@@ -70,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),
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a
|
||||
//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares.
|
||||
|
||||
use super::lookup::{annots, page_ref};
|
||||
use super::threed::MAX_MODEL_BYTES;
|
||||
use super::{AnnotError, Dict, Document, Object};
|
||||
|
||||
/// The format of a 3D stream, from its `/Subtype`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Model3dKind {
|
||||
/// Product Representation Compact (ISO 14739-1).
|
||||
Prc,
|
||||
/// Universal 3D (ECMA-363).
|
||||
U3d,
|
||||
}
|
||||
|
||||
/// A 3D annotation's model: its format and the decoded stream.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Model3d {
|
||||
pub kind: Model3dKind,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`].
|
||||
pub fn model_3d_annotations(doc: &Document, page: usize) -> Result<Vec<usize>, AnnotError> {
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect())
|
||||
}
|
||||
|
||||
/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`]
|
||||
/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or
|
||||
/// U3D, or decodes past [`MAX_MODEL_BYTES`].
|
||||
pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result<Model3d, AnnotError> {
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1));
|
||||
if annot.name(b"Subtype") != Some(b"3D") {
|
||||
return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at())));
|
||||
}
|
||||
let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?;
|
||||
let Object::Stream(s) = stream.as_ref() else {
|
||||
return Err(AnnotError::Invalid(format!("{} has no 3D stream", at())));
|
||||
};
|
||||
let kind = match s.dict.name(b"Subtype") {
|
||||
Some(b"PRC") => Model3dKind::Prc,
|
||||
Some(b"U3D") => Model3dKind::U3d,
|
||||
other => {
|
||||
let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned());
|
||||
return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at())));
|
||||
}
|
||||
};
|
||||
let data = doc
|
||||
.with_direct_parms(s)
|
||||
.decoded_within(MAX_MODEL_BYTES)
|
||||
.map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?;
|
||||
Ok(Model3d { kind, data })
|
||||
}
|
||||
|
||||
/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference
|
||||
/// dictionary. A reference is followed once; the caller refuses anything that is not a stream.
|
||||
fn stream_of(doc: &Document, annot: &Dict) -> Option<std::sync::Arc<Object>> {
|
||||
let obj = doc.resolve(annot.get(b"3DD")?);
|
||||
match obj.as_ref() {
|
||||
Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)),
|
||||
_ => Some(obj),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on.
|
||||
fn doc(annots: &str, extra: &[Vec<u8>]) -> Document {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(),
|
||||
];
|
||||
objs.extend(extra.iter().cloned());
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
fn stream(dict: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes();
|
||||
s.extend_from_slice(body);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
}
|
||||
|
||||
fn annot(dd: &str) -> Vec<u8> {
|
||||
format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_3d_stream_is_read_through_its_filters() {
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]);
|
||||
assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3dref_leads_to_the_stream_it_names() {
|
||||
let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
|
||||
assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u3d_stream_is_read_and_says_so() {
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]);
|
||||
assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3d_annotation_without_a_stream_is_invalid() {
|
||||
let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_format_is_invalid() {
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3dref_is_followed_once_and_only_to_a_stream() {
|
||||
// A reference naming another reference, or one that names itself, is damage: no stream.
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
|
||||
// The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key.
|
||||
let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_annotation_is_not_a_3d_one() {
|
||||
let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation")));
|
||||
assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 }));
|
||||
assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1]));
|
||||
assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1)));
|
||||
}
|
||||
@@ -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 | ||||