Merge remote-tracking branch 'upstream/main' into HEAD

This commit is contained in:
mtlcl410
2026-11-05 02:35:07 +08:00
94 changed files with 11782 additions and 169 deletions
+2 -1
View File
@@ -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 (256)
| 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. |
+11
View File
@@ -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"
Generated
+9
View File
@@ -4551,6 +4551,15 @@ dependencies = [
"zbus",
]
[[package]]
name = "pdfcraft-prc"
version = "0.18.1"
dependencies = [
"flate2",
"proptest",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-preflight"
version = "0.18.1"
+7
View File
@@ -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"]
+2
View File
@@ -231,6 +231,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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.
+19 -10
View File
@@ -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));
+8
View File
@@ -23,6 +23,7 @@ mod draw;
mod info;
mod lookup;
mod modify;
mod threed;
mod types;
#[cfg(test)]
@@ -41,6 +42,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,
@@ -70,6 +72,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),
}
+135
View File
@@ -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"U3Drest");
assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1");
let mut doc = fixture();
doc.set_version("2.0");
add(&mut doc, b"PRC rest");
assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2");
let mut doc = fixture();
let root = doc.root().unwrap();
let mut level3 = Dict::new();
level3.set(b"BaseVersion".to_vec(), Object::name("1.7"));
level3.set(b"ExtensionLevel".to_vec(), Object::Int(3));
let mut ext = Dict::new();
ext.set(b"ADBE".to_vec(), Object::Dict(level3));
ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new()));
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap();
add(&mut doc, b"PRC rest");
assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept");
ext.remove(b"ADBE");
let mut doc = fixture();
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap();
add(&mut doc, b"PRC rest");
assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept");
let mut doc = fixture();
let mut ext = Dict::new();
ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())]));
let ext = doc.add(Object::Dict(ext));
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap();
add(&mut doc, b"PRC rest");
let doc = reopen(&doc);
let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap());
assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone");
}
#[test]
fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() {
let at = [10.0, 20.0, 110.0, 70.0];
assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square");
assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm"));
for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] {
assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}");
}
}
/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by.
#[test]
fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() {
let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at };
for (poster, why) in [
(picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"),
(picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"),
(picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"),
(picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"),
] {
let mut doc = fixture();
let new = NewModel3d {
page: 0,
rect: [100.0, 100.0, 300.0, 300.0],
model: b"PRC rest",
activation: Activation3d::default(),
poster: Some(poster),
contents: "",
};
assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}");
assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added");
}
}
/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture
/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the
/// rectangle.
#[test]
fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() {
let mut doc = fixture();
let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858];
let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at };
let new = NewModel3d {
page: 0,
rect: [0.0, 100.0, 3.6, 110.8],
model: b"PRC rest",
activation: Activation3d::default(),
poster: Some(poster),
contents: "",
};
assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1));
}
#[test]
fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
let mut doc = fixture();
+292
View File
@@ -0,0 +1,292 @@
//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page,
//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated.
use super::create::{finite, normalize, num_array};
use super::lookup::{annots, page_ref, set_annots};
use super::types::{FLAG_PRINT, Meta};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n};
/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the
/// model is held and compressed in memory.
pub const MAX_MODEL_BYTES: usize = 256 << 20;
/// What the poster says when there is no picture.
const PLACEHOLDER_LABEL: &str = "3D model";
/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModelFormat {
U3d,
Prc,
}
impl ModelFormat {
/// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file
/// header block type), PRC files start `PRC`.
///
/// # Errors
///
/// [`AnnotError::NotAModel`] for anything else.
pub fn detect(bytes: &[u8]) -> Result<Self, AnnotError> {
if bytes.starts_with(b"U3D\0") {
Ok(Self::U3d)
} else if bytes.starts_with(b"PRC") {
Ok(Self::Prc)
} else {
Err(AnnotError::NotAModel)
}
}
/// The 3D stream's `/Subtype`.
pub fn subtype(self) -> &'static str {
match self {
Self::U3d => "U3D",
Self::Prc => "PRC",
}
}
/// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream
/// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard).
pub fn version(self) -> &'static str {
match self {
Self::U3d => "1.6",
Self::Prc => "1.7",
}
}
}
/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's
/// `/A`). The standard's default is [`Self::Click`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Activation {
/// `/XA`: when the user clicks it; it prints as its poster.
#[default]
Click,
/// `/PO`: when the page opens.
PageOpen,
/// `/PV`: when the page becomes visible.
PageVisible,
}
impl Activation {
/// Every activation, with the word a tool argument names it by.
pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")];
/// The `/A` name.
pub fn name(self) -> &'static str {
match self {
Self::Click => "XA",
Self::PageOpen => "PO",
Self::PageVisible => "PV",
}
}
}
/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it
/// to `size` points) drawn into `at`, a box inside the annotation's rectangle.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PosterPicture {
pub xobject: ObjRef,
pub image: bool,
pub size: (f64, f64),
pub at: [f64; 4],
}
/// A 3D model to add on `page` (0-based) in `rect` (user space).
#[derive(Clone, Debug, PartialEq)]
pub struct NewModel3d<'a> {
pub page: usize,
pub rect: [f64; 4],
pub model: &'a [u8],
pub activation: Activation,
/// The poster's picture; `None` draws a labelled frame.
pub poster: Option<PosterPicture>,
/// `/Contents`: the annotation's alternate description (§12.5.2).
pub contents: &'a str,
}
/// Check a model before anything is added: its size and its format.
///
/// # Errors
///
/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`].
pub fn check_model(model: &[u8]) -> Result<ModelFormat, AnnotError> {
check_model_size(model, MAX_MODEL_BYTES)?;
ModelFormat::detect(model)
}
fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> {
if model.len() > max {
return Err(AnnotError::ModelTooLarge { size: model.len(), max });
}
Ok(())
}
/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the
/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`.
/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document
/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`.
///
/// # Errors
///
/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no
/// area or a poster box outside it.
pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result<usize, AnnotError> {
let format = check_model(new.model)?;
let page = page_ref(doc, new.page)?;
let rect = normalize(new.rect);
if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into()));
}
let poster = match &new.poster {
Some(p) => picture_poster(rect, p)?,
None => placeholder_poster(rect),
};
let mut stream = Dict::new();
stream.set(b"Type".to_vec(), Object::name("3D"));
stream.set(b"Subtype".to_vec(), Object::name(format.subtype()));
let stream = doc.add(Object::Stream(Stream::flate(stream, new.model)));
let poster = doc.add(Object::Stream(poster));
let d = annotation_dict(new, rect, page, stream, poster, meta);
let r = doc.add(Object::Dict(d));
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
set_annots(doc, page, list)?;
at_least_version(doc, format.version())?;
if format == ModelFormat::Prc && !at_least(doc, "2.0") {
declare_prc_extension(doc)?;
}
Ok(index)
}
/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2).
fn at_least(doc: &Document, min: &str) -> bool {
let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes();
let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()));
let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached)
}
/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares
/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry
/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone.
fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> {
let Some(root) = doc.root() else { return Ok(()) };
let catalog = doc.get(root);
// `None` when present but not a dictionary; an empty one when absent.
let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) {
None => Some(Dict::new()),
Some(o) => o.as_dict().cloned(),
};
let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) };
let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) };
if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) {
return Ok(());
}
adbe.set(b"BaseVersion".to_vec(), Object::name("1.7"));
adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1));
extensions.set(b"ADBE".to_vec(), Object::Dict(adbe));
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?;
Ok(())
}
/// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in
/// its catalog (§7.7.2: the later of the two applies).
fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> {
let Some(root) = doc.root() else { return Ok(()) };
if !at_least(doc, min) {
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?;
}
Ok(())
}
fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("3D"));
d.set(b"Rect".to_vec(), num_array(&rect));
d.set(b"P".to_vec(), Object::Ref(page));
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT));
if !new.contents.is_empty() {
d.set(b"Contents".to_vec(), PdfString::text(new.contents));
}
if !meta.id.is_empty() {
d.set(b"NM".to_vec(), PdfString::text(&meta.id));
}
if let Some(date) = &meta.date {
d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
}
d.set(b"3DD".to_vec(), Object::Ref(stream));
let mut activation = Dict::new();
activation.set(b"A".to_vec(), Object::name(new.activation.name()));
d.set(b"3DA".to_vec(), Object::Dict(activation));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(poster));
d.set(b"AP".to_vec(), Object::Dict(ap));
d
}
/// The poster drawing `p` into its box, which must lie within `rect`.
fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result<Stream, AnnotError> {
let at = normalize(p.at);
// A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp.
let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9;
let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack;
let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])];
if !(near && at[2] > at[0] && at[3] > at[1]) {
return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into()));
}
let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?;
let mut xobjects = Dict::new();
xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources))
}
/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't).
fn placeholder_poster(rect: [f64; 4]) -> Stream {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
let mut c =
format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0));
let mut resources = Dict::new();
let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0);
let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 };
if size >= 2.0 {
let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0);
c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty)));
resources.set(b"Font".to_vec(), appearance::helvetica_resource());
}
appearance::form(rect, c.as_bytes(), resources)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_size_is_capped() {
assert_eq!(check_model_size(&[0; 8], 8), Ok(()));
assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 }));
}
#[test]
fn the_format_is_read_from_the_header() {
assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d));
assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc));
for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] {
assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}");
}
}
#[test]
fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() {
let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap();
assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}");
let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]);
assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj"));
assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none()));
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Bookmark trees in and out of documents: `bookmark_export`, `bookmark_import`, `bookmark_copy`.
//!
//! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page`
//! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination
//! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`,
//! `open` and `children`. What export writes, import takes back unchanged. A file also carries
//! `"truncated": true` when the export left out bookmarks nested past the listing limit.
use crate::{Args, Automation, Result, ToolError, failed};
use pdfcraft_engine::{DocId, Edit, OutlineNode};
use serde_json::{Value, json};
/// Deepest nesting a bookmark tree may have (the listing reaches 33 levels).
const MAX_DEPTH: usize = 33;
/// The keys a node may have: the ones [`node_json`] writes.
const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"];
impl Automation {
pub(crate) fn bookmark_export(&self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let path = a.opt_path("path")?.unwrap_or_default();
let tree = self.outline_of(id, &path)?;
let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::<Vec<_>>(), "truncated": tree.truncated });
if let Some(file) = a.opt_str("out")? {
let target = self.resolve(file, true)?;
let mut saved = json!({ "bookmarks": out["bookmarks"] });
if tree.truncated {
saved["truncated"] = json!(true);
}
let text = serde_json::to_vec_pretty(&saved).map_err(failed)?;
crate::write_atomic(&target, &text)?;
out["path"] = json!(target);
}
Ok(out)
}
pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result<Value> {
let (nodes, truncated) = match (a.get("bookmarks"), a.opt_str("file")?) {
(Some(list), None) => (nodes_of(list)?, false),
(None, Some(file)) => {
let path = self.resolve(file, false)?;
let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?;
let list = value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?;
(nodes_of(list)?, value.get("truncated") == Some(&Value::Bool(true)))
}
_ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())),
};
let mut out = self.write_tree(a, nodes)?;
out["truncated"] = json!(truncated);
Ok(out)
}
pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result<Value> {
let from = a.int("from")?;
let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?);
let path = a.opt_path("path")?.unwrap_or_default();
let tree = self.outline_of(from, &path)?;
let mut out = self.write_tree(a, tree.nodes)?;
out["truncated"] = json!(tree.truncated);
Ok(out)
}
fn outline_of(&self, id: DocId, path: &[usize]) -> Result<pdfcraft_engine::OutlineTree> {
match self.session.outline_tree(id, path) {
// The engine names the path 0-based; the caller gave it 1-based.
Some(tree) => tree.map_err(|e| match e {
pdfcraft_organize::OutlineError::NoSuchBookmark(_) => failed(format!("there is no bookmark at {:?}", one_based(path))),
e => failed(e),
}),
None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))),
}
}
/// Write `nodes` into `doc` under `parent` at `position` (or in place of the outline with
/// `replace`), and say which of them went nowhere because the document lacks their page.
fn write_tree(&mut self, a: &Args, bookmarks: Vec<OutlineNode>) -> Result<Value> {
let parent = a.opt_path("parent")?.unwrap_or_default();
let index = a.opt_int("position")?.map_or(usize::MAX, |p| usize::try_from(p.max(1) - 1).unwrap_or(usize::MAX));
let replace = a.opt_bool("replace")?.unwrap_or(false);
let count = self.doc(a)?.info.pages.len();
let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, count);
let mut out = self.apply(a, Edit::ImportBookmarks { parent, index, bookmarks, replace })?;
out["unmapped"] = json!(unmapped.iter().map(|p| one_based(p)).collect::<Vec<_>>());
Ok(out)
}
}
fn one_based(path: &[usize]) -> Vec<usize> {
path.iter().map(|i| i.saturating_add(1)).collect()
}
/// A node as JSON, leaving out what is at its default (no view, black, regular, no children).
fn node_json(n: &OutlineNode) -> Value {
let mut v = json!({ "title": n.title, "page": n.page.map(|p| p.saturating_add(1)), "open": n.open });
if let Some((kind, values)) = &n.view {
v["view"] = json!({ "type": String::from_utf8_lossy(kind), "values": values });
}
if let Some(c) = n.color {
v["color"] = json!(c);
}
if n.bold {
v["bold"] = json!(true);
}
if n.italic {
v["italic"] = json!(true);
}
if !n.children.is_empty() {
v["children"] = json!(n.children.iter().map(node_json).collect::<Vec<_>>());
}
v
}
fn nodes_of(list: &Value) -> Result<Vec<OutlineNode>> {
let mut count = 0;
list_of(list, &[], &mut count)
}
/// The nodes of one level; `at` is the 1-based path of their parent, for messages.
fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result<Vec<OutlineNode>> {
let items = list.as_array().ok_or_else(|| bad(at, "children must be a list of bookmarks"))?;
if at.len() >= MAX_DEPTH && !items.is_empty() {
return Err(bad(at, &format!("bookmarks may nest at most {MAX_DEPTH} levels deep")));
}
*count = count.saturating_add(items.len());
if *count > pdfcraft_engine::MAX_IMPORT_BOOKMARKS {
return Err(ToolError::InvalidArgs(format!("at most {} bookmarks can be imported at once", pdfcraft_engine::MAX_IMPORT_BOOKMARKS)));
}
let mut out = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
let mut path = at.to_vec();
path.push(i.saturating_add(1));
out.push(node_of(item, &path, count)?);
}
Ok(out)
}
/// One node and, through [`list_of`], its children (recursion bounded by `MAX_DEPTH`).
fn node_of(v: &Value, at: &[usize], count: &mut usize) -> Result<OutlineNode> {
let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?;
if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) {
return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", "))));
}
let field = |k: &str| o.get(k).filter(|x| !x.is_null());
// An empty title is kept: files have them, and export writes them as "".
let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string();
let page = match field("page") {
None => None,
Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?),
};
let flag = |k: &str| field(k).map_or(Ok(None), |x| x.as_bool().map(Some).ok_or_else(|| bad(at, &format!("{k} must be true or false"))));
let color = match field("color") {
None => None,
Some(c) => {
let rgb: Option<Vec<f32>> = c.as_array().and_then(|c| c.iter().map(|x| x.as_f64().map(|x| x as f32)).collect());
match rgb.as_deref() {
Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]),
_ => return Err(bad(at, "color must be [r, g, b], each from 0 to 1")),
}
}
};
let children = match field("children") {
None => Vec::new(),
Some(list) => list_of(list, at, count)?,
};
Ok(OutlineNode {
title,
page,
view: field("view").map(|x| view_of(x, at)).transpose()?,
color,
italic: flag("italic")?.unwrap_or(false),
bold: flag("bold")?.unwrap_or(false),
open: flag("open")?.unwrap_or(true),
children,
})
}
/// `{"type": "XYZ", "values": [left, top, zoom]}`: a destination type with exactly its values.
fn view_of(v: &Value, at: &[usize]) -> Result<(Vec<u8>, Vec<Option<f64>>)> {
let kind = v.get("type").and_then(Value::as_str).ok_or_else(|| bad(at, "view needs a type such as XYZ or Fit"))?;
let values = match v.get("values").filter(|x| !x.is_null()) {
None => Vec::new(),
Some(list) => {
let list = list.as_array().filter(|l| l.len() <= 4).ok_or_else(|| bad(at, "view values must be a list of at most 4 numbers or nulls"))?;
list.iter()
.map(|x| match x {
Value::Null => Ok(None),
x => x.as_f64().map(Some).ok_or_else(|| bad(at, "view values must be numbers or null")),
})
.collect::<Result<_>>()?
}
};
let arity = pdfcraft_engine::outline_view_arity(kind.as_bytes()).ok_or_else(|| bad(at, VIEW_TYPES))?;
if values.len() != arity {
return Err(bad(at, &format!("a {kind} view takes {arity} values (numbers, or null to keep the reader's)")));
}
if kind == "FitR" && values.iter().any(Option::is_none) {
return Err(bad(at, "a FitR view needs all four values: left, bottom, right, top"));
}
Ok((kind.as_bytes().to_vec(), values))
}
const VIEW_TYPES: &str = "view type must be XYZ (3 values: left, top, zoom), Fit, FitB (none), FitH, FitV, FitBH, FitBV (1) or FitR (4)";
fn bad(at: &[usize], what: &str) -> ToolError {
if at.is_empty() {
return ToolError::InvalidArgs(format!("bookmarks: {what}"));
}
ToolError::InvalidArgs(format!("bookmark {at:?}: {what}"))
}
+1 -1
View File
@@ -75,7 +75,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
[ux as f64, uy as f64]
}
fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3]));
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
+4
View File
@@ -196,6 +196,9 @@ impl Automation {
out
}
"bookmark_list" => self.bookmark_listing(&a)?,
"bookmark_export" => self.bookmark_export(&a)?,
"bookmark_import" => self.bookmark_import(&a)?,
"bookmark_copy" => self.bookmark_copy(&a)?,
"bookmark_add" => {
let page = self.page(&a)?;
let parent = a.opt_path("parent")?.unwrap_or_default();
@@ -769,6 +772,7 @@ impl Automation {
"comment_list" => self.comment_list(&a)?,
"comment_add" => self.comment_add(&a)?,
"stamp_custom" => self.stamp_custom(&a)?,
"model_add" => self.model_add(&a)?,
"comment_reply" => self.comment_reply(&a)?,
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
+12 -2
View File
@@ -394,8 +394,18 @@ mod tests {
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
// inputs and out_dir, walks only inside them (skipping links) and resolves every
// discovered file again before `process` reads it or `publish` writes it.
const RESOLVED_BY_CALLER: &[&str] =
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
const RESOLVED_BY_CALLER: &[&str] = &[
"write_atomic",
"write_atomic_with",
"read_input",
"read_input_bytes",
"read_model",
"read_review_sidecar",
"process",
"linked",
"walk",
"publish",
];
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut files = vec![];
while let Some(dir) = dirs.pop() {
+2
View File
@@ -27,6 +27,7 @@ mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
mod measure;
mod model3d;
mod printing;
mod reading;
mod redact;
@@ -50,6 +51,7 @@ use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
mod bookmark_io;
mod dispatch;
mod documents;
mod error;
+1 -1
View File
@@ -557,7 +557,7 @@ fn tool_json(t: &crate::ToolDef) -> Value {
"title": t.title,
"description": t.description,
"inputSchema": t.input_schema,
"annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false },
"annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save" | "bookmark_export"), "openWorldHint": false },
})
}
+114
View File
@@ -0,0 +1,114 @@
//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3).
use std::path::Path;
use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat};
use serde_json::Value;
use crate::comments::rect_to_user;
use crate::{Args, Automation, Result, ToolError, failed};
/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
const MAX_MODEL_SIDE: f64 = 14_400.0;
impl Automation {
/// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an
/// image) fitted inside the box, or a labelled frame.
pub(crate) fn model_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?;
let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?;
let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) {
return Err(ToolError::InvalidArgs(format!(
"rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt"
)));
}
let activation = match a.opt_str("activation")? {
None => Activation3d::default(),
Some(word) => Activation3d::ALL
.iter()
.find(|(_, w)| *w == word)
.map(|(act, _)| *act)
.ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?,
};
let path = self.resolve(a.str("path")?, false)?;
let model = read_model(&path, MAX_3D_MODEL_BYTES)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
let poster = match a.opt_str("poster")? {
None => None,
Some(p) => {
let path = self.resolve(p, false)?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1);
Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page })
}
};
let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name };
self.apply(a, edit)
}
}
/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first
/// bytes are checked before the rest is read, so a large file that is not a model is never loaded.
fn read_model(path: &Path, max: usize) -> Result<Vec<u8>> {
use std::io::Read as _;
let io = |e: std::io::Error| failed(format!("{}: {e}", path.display()));
let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}"));
let cap = u64::try_from(max).unwrap_or(u64::MAX);
let mut file = std::fs::File::open(path).map_err(io)?;
if let Ok(meta) = file.metadata()
&& meta.is_file()
&& meta.len() > cap
{
return Err(too_large(meta.len()));
}
let mut bytes = Vec::new();
(&mut file).take(4).read_to_end(&mut bytes).map_err(io)?;
Model3dFormat::detect(&bytes).map_err(failed)?;
let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64);
file.take(rest).read_to_end(&mut bytes).map_err(io)?;
if bytes.len() > max {
return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted")));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn a_model_is_read_whole_when_it_fits() {
let dir = tempfile::tempdir().unwrap();
let mut prc = b"PRC".to_vec();
prc.extend_from_slice(&[7; 40]);
assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc);
assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x");
}
#[test]
fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() {
let dir = tempfile::tempdir().unwrap();
let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]);
let err = read_model(&path, 4).unwrap_err().to_string();
assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}");
}
#[test]
fn a_file_that_is_not_a_model_is_refused_by_its_header() {
let dir = tempfile::tempdir().unwrap();
for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] {
let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string();
assert!(err.contains("not a 3D model"), "{junk:?}: {err}");
}
}
}
+58
View File
@@ -323,6 +323,50 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "title", "page"],
)),
t(
"bookmark_export",
"Export bookmarks",
"The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}, with \"truncated\": true when that is so; out is overwritten.",
)
.destructive()
.with(schema(
json!({ "doc": doc(), "path": path("The bookmark whose subtree to export (omit for the whole outline)"), "out": path_arg() }),
&["doc"],
)),
t(
"bookmark_import",
"Import bookmarks",
"Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing; a key a node does not have is refused. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.",
)
.destructive()
.with(schema(
json!({
"doc": doc(),
"bookmarks": { "type": "array", "items": { "type": "object" }, "minItems": 1, "description": "Nodes as bookmark_export returns them." },
"file": path_arg(),
"parent": path("Parent bookmark (omit for the top level)"),
"position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
"replace": { "type": "boolean", "description": "Replace the document's bookmarks instead of adding to them (default false; leave parent out)." }
}),
&["doc"],
)),
t(
"bookmark_copy",
"Copy bookmarks from another document",
"Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). truncated is true when bookmarks nested deeper than 33 levels were left out of the copy. Undoable.",
)
.destructive()
.with(schema(
json!({
"doc": doc(),
"from": { "type": "integer", "minimum": 1, "description": "Id of the document to copy from." },
"path": path("The bookmark whose subtree to copy (omit for the whole outline)"),
"parent": path("Parent bookmark in doc (omit for the top level)"),
"position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
"replace": { "type": "boolean", "description": "Replace doc's bookmarks instead of adding to them (default false; leave parent out)." }
}),
&["doc", "from"],
)),
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
.with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "title": { "type": "string", "minLength": 1 } }), &["doc", "path", "title"])),
t("bookmark_delete", "Delete a bookmark", "Delete a bookmark and the bookmarks under it. Undoable.")
@@ -469,6 +513,20 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "path"],
)),
t("model_add", "Add a 3D model", "Add a 3D model (Acrobat: Rich Media ▸ Add 3D): the U3D or PRC file at path, recognised by its first bytes, as a 3D annotation in rect [x0, y0, x1, y1] (points from the top-left of the displayed page; each side 1–14400 pt; at most 256 MiB). Its poster, what prints and what readers show until the model is activated, is the picture at poster (a PDF page, poster_page default 1, or an image) fitted inside rect keeping its shape and centred; without poster, a frame labelled \"3D model\". activation: click (default), page_open or page_visible. name is its description (default: the file name). No views are added: readers open the model on its own default view. Undoable.")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"path": { "type": "string" },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"poster": { "type": "string" },
"poster_page": { "type": "integer", "minimum": 1 },
"activation": { "type": "string", "enum": ["click", "page_open", "page_visible"] },
"name": { "type": "string" },
}),
&["doc", "page", "path", "rect"],
)),
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
t(
+190
View File
@@ -10300,6 +10300,31 @@ fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_
assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
}
/// Clearing a field's length limit through `form_set_props` (`"max_length": 0`) leaves it with
/// none, even when its parent field has one it would otherwise inherit.
#[test]
fn clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents() {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>",
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
"<< /T (address) /FT /Tx /MaxLen 3 /Kids [6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /MaxLen 8 /Rect [10 250 150 268] /P 3 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
];
let dir = workdir("kid-max-len");
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "max_length": 0 }));
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Absalom" } }));
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
assert!(city.get("max_length").is_none(), "no limit, not the parent's 3: {city}");
assert_eq!(city["value"], "Absalom", "the value is not cut to the parent's 3 characters");
}
/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
/// even when its parent field has one it would otherwise inherit.
#[test]
@@ -10748,3 +10773,168 @@ fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() {
assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical");
std::fs::remove_dir_all(dir).unwrap();
}
/// Export, then import into another document and copy between open documents: the tree comes
/// back unchanged, a page the target lacks is reported, and the import undoes in one step.
#[test]
fn bookmarks_export_import_and_copy_between_documents() {
let dir = workdir("bookmark-io");
let mut a = auto(&dir);
let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Intro", "page": 1 }));
ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Detail", "page": 3, "parent": [1] }));
ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "End", "page": 2 }));
ok(&mut a, "bookmark_set_style", json!({ "doc": src, "path": [1], "color": [1, 0, 0], "bold": true }));
let exported = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "marks.json" }));
assert_eq!(exported["truncated"], false);
let tree = exported["bookmarks"].clone();
assert_eq!((tree[0]["title"].clone(), tree[0]["color"].clone(), tree[0]["bold"].clone()), (json!("Intro"), json!([1.0, 0.0, 0.0]), json!(true)));
assert_eq!((tree[0]["children"][0]["title"].clone(), tree[0]["children"][0]["page"].clone()), (json!("Detail"), json!(3)));
assert_eq!(tree[0]["view"]["type"], "XYZ");
let subtree = ok(&mut a, "bookmark_export", json!({ "doc": src, "path": [1, 1] }))["bookmarks"].clone();
assert_eq!(subtree, json!([tree[0]["children"][0]]));
// b.pdf has two pages: Detail goes to the third, so it arrives without a destination.
let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
let imported = ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "marks.json" }));
assert_eq!(imported["unmapped"], json!([[1, 1]]));
let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone();
assert_eq!(back[0]["children"][0]["page"], json!(null));
assert_eq!((back[0]["title"].clone(), back[1].clone()), (tree[0]["title"].clone(), tree[1].clone()));
ok(&mut a, "edit_undo", json!({ "doc": dst }));
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": dst }))["bookmarks"], json!([]));
// Into a document with every page, the tree is unchanged, wherever it is copied from.
let same = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "bookmark_add", json!({ "doc": same, "title": "Old", "page": 1 }));
let copied = ok(&mut a, "bookmark_copy", json!({ "doc": same, "from": src, "replace": true }));
assert_eq!(copied["unmapped"], json!([]));
assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": same }))["bookmarks"], tree);
ok(&mut a, "bookmark_import", json!({ "doc": same, "bookmarks": subtree, "parent": [2], "position": 1 }));
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": same }))["bookmarks"][1]["children"][0]["title"], "Detail");
}
/// A one-page document whose outline has an untitled item, a /FitR destination with three of
/// its four numbers, and a chain nested `deep` levels below them.
fn odd_outline(deep: usize) -> Vec<u8> {
let first_deep = 7;
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Outlines /First 5 0 R /Last 6 0 R >>".into(),
"<< /Title () /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into(),
format!(
"<< /Title (Short) /Parent 4 0 R /Prev 5 0 R /Dest [3 0 R /FitR 0 0 10]{} >>",
if deep > 0 { format!(" /First {first_deep} 0 R /Last {first_deep} 0 R") } else { String::new() }
),
];
for i in 0..deep {
let (me, parent) = (first_deep + i, if i == 0 { 6 } else { first_deep + i - 1 });
let child = if i + 1 < deep { format!(" /First {0} 0 R /Last {0} 0 R", me + 1) } else { String::new() };
objs.push(format!("<< /Title (Level) /Parent {parent} 0 R /Dest [3 0 R /Fit]{child} >>"));
}
pdf_of(&objs.iter().map(String::as_str).collect::<Vec<_>>())
}
/// What a real file holds and the JSON form can't say exactly still copies and round-trips: an
/// empty title stays empty, a short /FitR keeps its page and drops the view, and bookmarks past
/// the listing depth are reported as left out at every step.
#[test]
fn odd_bookmarks_round_trip_and_a_cut_tree_says_so() {
let dir = workdir("bookmark-io-odd");
std::fs::write(dir.join("odd.pdf"), odd_outline(0)).unwrap();
std::fs::write(dir.join("deep.pdf"), odd_outline(40)).unwrap();
let mut a = auto(&dir);
let src = ok(&mut a, "doc_open", json!({ "path": "odd.pdf" }))["doc"].as_u64().unwrap();
let tree = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "odd.json" }))["bookmarks"].clone();
assert_eq!((tree[0]["title"].clone(), tree[1]["page"].clone(), tree[1].get("view")), (json!(""), json!(1), None));
let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "odd.json", "replace": true }))["truncated"], false);
// A destination with no view is written as XYZ with nulls: the same page, the reader's view.
let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone();
assert_eq!((back[0].clone(), back[1]["title"].clone(), back[1]["page"].clone()), (tree[0].clone(), json!("Short"), json!(1)));
assert_eq!(back[1]["view"], json!({ "type": "XYZ", "values": [null, null, null] }));
let copied = ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": src, "replace": true }));
assert_eq!((copied["unmapped"].clone(), copied["truncated"].clone()), (json!([]), json!(false)));
match a.call("bookmark_export", &json!({ "doc": src, "path": [3] })) {
Err(ToolError::Failed(m)) => assert!(m.contains("no bookmark at [3]"), "{m}"),
other => panic!("expected no bookmark, got {other:?}"),
}
let deep = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": deep, "out": "deep.json" }))["truncated"], true);
let saved: Value = serde_json::from_slice(&std::fs::read(dir.join("deep.json")).unwrap()).unwrap();
assert_eq!(saved["truncated"], true);
assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "deep.json", "replace": true }))["truncated"], true);
assert_eq!(ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": deep, "replace": true }))["truncated"], true);
let export = tools().into_iter().find(|t| t.name == "bookmark_export").unwrap();
assert!(export.destructive && !export.read_only, "bookmark_export overwrites out");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_misspelt_bookmark_key_is_refused_not_dropped() {
let dir = workdir("bookmark-io-key");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let tree = json!([{ "title": "Parent", "children": [{ "title": "Red", "page": 1, "colour": [1, 0, 0] }] }]);
match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": tree })) {
Err(ToolError::InvalidArgs(m)) => assert!(m.contains("[1, 1]") && m.contains("\"colour\""), "{m}"),
other => panic!("expected InvalidArgs, got {other:?}"),
}
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() {
let dir = workdir("bookmark-io-bad");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let refused = |a: &mut Automation, bookmarks: Value| match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": bookmarks })) {
Err(ToolError::InvalidArgs(m)) => m,
other => panic!("expected InvalidArgs, got {other:?}"),
};
let fine = json!({ "title": "Fine", "page": 1 });
let m = refused(&mut a, json!([fine, { "title": "Parent", "children": [{ "page": 1 }] }]));
assert!(m.contains("[2, 1]") && m.contains("title"), "{m}");
let m = refused(&mut a, json!([{ "title": 5 }]));
assert!(m.contains("[1]") && m.contains("title"), "{m}");
let m = refused(&mut a, json!([fine, { "title": "Bad", "page": 0 }]));
assert!(m.contains("[2]") && m.contains("page"), "{m}");
let m = refused(&mut a, json!([{ "title": "Bad", "color": [2, 0, 0] }]));
assert!(m.contains("color"), "{m}");
let m = refused(&mut a, json!([{ "title": "Bad", "view": { "type": "XYZ", "values": [1, 2, 3, 4, 5] } }]));
assert!(m.contains("view"), "{m}");
let mut deep = json!({ "title": "Leaf" });
for _ in 0..40 {
deep = json!({ "title": "Level", "children": [deep] });
}
assert!(refused(&mut a, json!([deep])).contains("33 levels"));
let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "Zoom" } }]));
assert!(m.contains("[2]") && m.contains("XYZ (3 values"), "{m}");
let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "FitH", "values": [1, 2] } }]));
assert!(m.contains("[2]") && m.contains("FitH view takes 1"), "{m}");
let m = refused(&mut a, json!([{ "title": "R", "view": { "type": "FitR", "values": [0, null, 1, 1] } }]));
assert!(m.contains("all four"), "{m}");
assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": [fine], "file": "x.json" })), Err(ToolError::InvalidArgs(_))));
// A real bookmark file outside the root is refused for being outside, not for being missing.
let outside = dir.with_extension("outside.json");
std::fs::write(&outside, br#"{"bookmarks": [{"title": "Outside", "page": 1}]}"#).unwrap();
let name = format!("../{}", outside.file_name().unwrap().to_string_lossy());
match a.call("bookmark_import", &json!({ "doc": doc, "file": name })) {
Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"),
other => panic!("expected the root to refuse it, got {other:?}"),
}
let _ = std::fs::remove_file(&outside);
let out = dir.with_extension("out.json");
let name = format!("../{}", out.file_name().unwrap().to_string_lossy());
match a.call("bookmark_export", &json!({ "doc": doc, "out": name })) {
Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"),
other => panic!("expected the root to refuse it, got {other:?}"),
}
assert!(!out.exists());
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
}
@@ -145,6 +145,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() {
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin")));
let out = s(&base.join("out/result.pdf"));
std::fs::write(base.join("in/m.prc"), b"PRC model").unwrap();
let model = s(&base.join("in/m.prc"));
for (tool, args) in [
("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})),
("page_replace", json!({"doc": 1, "pages": [1], "path": secret})),
@@ -153,6 +155,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() {
("doc_create", json!({"from": "images", "paths": [pic]})),
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})),
("model_add", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 100, 100]})),
("model_add", json!({"doc": 1, "page": 1, "path": model, "rect": [10, 10, 100, 100], "poster": pic})),
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
+1
View File
@@ -8,6 +8,7 @@ mod batch;
mod button_caption;
mod form_bulk;
mod hostile_args;
mod model_3d;
mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
+120
View File
@@ -0,0 +1,120 @@
//! `model_add` embeds a U3D or PRC model as a 3D annotation (ISO 32000-2 §13.6.2) with a poster,
//! end to end: the tool, the saved file, undo, and the refusals.
use pdfcraft_automation::{Automation, Content, ToolError};
use pdfcraft_cos::{Dict, Document, Object, Stream};
use serde_json::{Value, json};
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(value) => value,
other => panic!("expected JSON: {other:?}"),
}
}
fn png(w: u32, h: u32) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header().unwrap().write_image_data(&vec![90; (w * h * 3) as usize]).unwrap();
out
}
fn stream(doc: &Document, obj: &Object) -> Stream {
match doc.resolve(obj).as_ref() {
Object::Stream(s) => s.clone(),
other => panic!("expected a stream: {other:?}"),
}
}
/// The 3D annotations on the first page of the saved file.
fn saved_3d(dir: &std::path::Path) -> (Document, Vec<Dict>) {
let doc = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out.pdf")).unwrap())).unwrap();
let page = &pdfcraft_model::pages(&doc)[0];
let list = page.dict.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap()).unwrap_or_default();
let found = list.iter().map(|o| doc.resolve(o).as_dict().unwrap().clone()).filter(|d| d.name(b"Subtype") == Some(&b"3D"[..])).collect();
(doc, found)
}
#[test]
fn model_add_embeds_the_model_with_a_fitted_poster() {
let dir = tempfile::tempdir().unwrap();
let mut prc = b"PRC".to_vec();
prc.extend_from_slice(&[7; 200]);
std::fs::write(dir.path().join("bracket.prc"), &prc).unwrap();
std::fs::write(dir.path().join("poster.png"), png(80, 40)).unwrap();
let mut a = Automation::new().with_root(dir.path()).unwrap();
let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap();
// A 300 × 300 box from the top-left of the page: user space [100, 400, 400, 700].
let call =
json!({ "doc": doc, "page": 1, "path": "bracket.prc", "rect": [100, 100, 400, 400], "poster": "poster.png", "activation": "page_open" });
tool(&mut a, "model_add", call);
tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
let (saved, found) = saved_3d(dir.path());
let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") };
let rect: Vec<f64> = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect();
assert_eq!(rect, [100.0, 400.0, 400.0, 700.0]);
assert_eq!(annot.get(b"Contents").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("bracket"));
let model = stream(&saved, annot.get(b"3DD").unwrap());
assert_eq!(model.dict.name(b"Type"), Some(&b"3D"[..]));
assert_eq!(model.dict.name(b"Subtype"), Some(&b"PRC"[..]));
assert_eq!(model.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
assert_eq!(model.decoded().unwrap().to_vec(), prc, "the model is stored unchanged");
let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone();
assert_eq!(activation.name(b"A"), Some(&b"PO"[..]));
// The 80 × 40 picture fitted in the 300 × 300 box: 300 × 150, centred top to bottom.
let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone();
let poster = stream(&saved, ap.get(b"N").unwrap());
let content = String::from_utf8(poster.decoded().unwrap().to_vec()).unwrap();
assert!(content.contains("300 0 0 150 100 475 cm /Pic Do"), "{content}");
assert_eq!(tool(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add 3D model");
tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
assert!(saved_3d(dir.path()).1.is_empty(), "undo removes it");
}
#[test]
fn model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click() {
let dir = tempfile::tempdir().unwrap();
let mut u3d = b"U3D\0".to_vec();
u3d.extend_from_slice(&[0; 60]);
std::fs::write(dir.path().join("part.u3d"), &u3d).unwrap();
let mut a = Automation::new().with_root(dir.path()).unwrap();
let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap();
tool(&mut a, "model_add", json!({ "doc": doc, "page": 1, "path": "part.u3d", "rect": [50, 50, 250, 200], "name": "Part" }));
tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
let (saved, found) = saved_3d(dir.path());
let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") };
assert_eq!(stream(&saved, annot.get(b"3DD").unwrap()).dict.name(b"Subtype"), Some(&b"U3D"[..]));
let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone();
assert_eq!(activation.name(b"A"), Some(&b"XA"[..]), "the standard's default, stated");
let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone();
let content = String::from_utf8(stream(&saved, ap.get(b"N").unwrap()).decoded().unwrap().to_vec()).unwrap();
assert!(content.contains("re S") && content.contains("(3D model) Tj"), "{content}");
}
#[test]
fn model_add_refuses_what_is_not_a_model_and_adds_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.txt"), b"PR not a model").unwrap();
std::fs::write(dir.path().join("part.prc"), b"PRC....").unwrap();
let mut a = Automation::new().with_root(dir.path()).unwrap();
let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap();
let not_a_model = a.call("model_add", &json!({ "doc": doc, "page": 1, "path": "notes.txt", "rect": [10, 10, 100, 100] }));
assert!(matches!(&not_a_model, Err(ToolError::Failed(m)) if m.contains("U3D") && m.contains("PRC")), "{not_a_model:?}");
for (args, why) in [
(json!({ "rect": [10, 10, 10, 100] }), "zero width"),
(json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"),
(json!({}), "no rect"),
(json!({ "rect": [10, 10, 100, 100], "activation": "now" }), "an unknown activation"),
] {
let mut call = json!({ "doc": doc, "page": 1, "path": "part.prc" });
call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone());
assert!(matches!(a.call("model_add", &call), Err(ToolError::InvalidArgs(_))), "{why}");
}
tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
assert!(saved_3d(dir.path()).1.is_empty());
}
+19 -2
View File
@@ -679,8 +679,9 @@ impl Document {
///
/// Used when the working snapshot was restored from an earlier revision and the next
/// incremental save must still append to this file (the last saved revision). Objects
/// that already match are left untouched. Reads go through private readers so neither
/// document's editing cache is filled.
/// that already match are left untouched, except members of an object stream of this file
/// whose stream number `current` frees or reuses: those are written again on their own.
/// Reads go through private readers so neither document's editing cache is filled.
pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> {
let saved_nums = self.object_numbers();
let current_nums = current.object_numbers();
@@ -704,6 +705,22 @@ impl Document {
}
}
}
// An object stream of this file that is freed or replaced takes its members with it, so
// a member that is otherwise unchanged is written again on its own.
let gone = |n: u32| freed.binary_search(&n).is_ok() || changed.binary_search(&n).is_ok();
let orphaned: Vec<u32> = current_nums
.iter()
.copied()
.filter(|&num| changed.binary_search(&num).is_err())
.filter(|&num| {
let packed_in = match self.xref_entry(num) {
Some(XrefEntry::InStream { stream, .. }) => Some(stream),
_ => None,
};
packed_in.is_some_and(gone)
})
.collect();
changed.extend(orphaned);
for num in freed {
let generation = self.generation(num);
self.free(ObjRef::new(num, generation));
+26
View File
@@ -1846,6 +1846,32 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
#[test]
fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() {
// The earlier revision: an empty document read back from a file whose catalog and pages
// sit in an object stream.
let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap();
// The saved revision adds an object the catalog links to, so a full save numbers its objects
// anew; an undo then returns to the earlier graph.
let mut later = earlier.clone();
let extra = later.add(Object::Int(7));
let root = later.root().unwrap();
later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap();
let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap();
let mut base = Document::open(Arc::new(saved)).unwrap();
assert!(
base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))),
"the saved file holds objects in a stream"
);
base.overlay_changes_from(&earlier).unwrap();
let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap();
let reread = Document::open(Arc::new(file)).unwrap();
let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone();
assert!(catalog.get(b"Extra").is_none(), "the restored catalog");
let pages = reread.resolve(catalog.get(b"Pages").unwrap());
assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}");
}
/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has
/// to measure the cache again on every read (#5873).
#[test]
+21 -1
View File
@@ -6,7 +6,7 @@ use crate::{
AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label,
LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState,
Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
mod labels;
@@ -119,6 +119,15 @@ pub enum Edit {
bold: Option<bool>,
italic: Option<bool>,
},
/// Write `bookmarks` as children of `parent` from child `index` on, or with `replace` in
/// place of the whole outline (`parent` must then be `[]`). A bookmark whose page this
/// document lacks is kept without a destination (see [`pdfcraft_organize::import_outline`]).
ImportBookmarks {
parent: Vec<usize>,
index: usize,
bookmarks: Vec<pdfcraft_organize::OutlineNode>,
replace: bool,
},
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
@@ -157,6 +166,17 @@ pub enum Edit {
file: MarkFile,
author: String,
},
/// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named
/// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an
/// image) fitted inside `rect`, keeping its shape and centred, or a labelled frame without one.
AddModel3d {
page: usize,
rect: [f64; 4],
model: Arc<Vec<u8>>,
poster: Option<MarkFile>,
activation: pdfcraft_annot::Activation3d,
name: String,
},
/// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies.
DeleteAnnotation {
page: usize,
+4
View File
@@ -38,6 +38,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Set bookmark destination",
"Colour",
"Style",
"Import bookmarks",
"New bookmarks from structure",
"New bookmarks from text styles",
"Number pages",
@@ -68,6 +69,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Add inserted text",
"Add file attachment",
"Add stamp",
"Add 3D model",
"Delete comment",
"Edit comment",
"Reply",
@@ -193,6 +195,7 @@ impl Edit {
// The bookmark Style and Colour menus' own names, already said in every language.
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
Edit::ImportBookmarks { .. } => Label::of("Import bookmarks"),
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
Edit::NumberPages { .. } => Label::of("Number pages"),
@@ -205,6 +208,7 @@ impl Edit {
Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"),
Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)),
Edit::AddCustomStamp { .. } => Label::of("Add stamp"),
Edit::AddModel3d { .. } => Label::of("Add 3D model"),
Edit::DeleteAnnotation { .. } => Label::of("Delete comment"),
Edit::SetAnnotationContents { .. } => Label::of("Edit comment"),
Edit::ReplyToAnnotation { .. } => Label::of("Reply"),
+16 -1
View File
@@ -1,6 +1,6 @@
use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
@@ -27,6 +27,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::SetBookmarkPage { .. }
| Edit::SetBookmarkColor { .. }
| Edit::SetBookmarkStyle { .. }
| Edit::ImportBookmarks { .. }
| Edit::BookmarksFromStructure
| Edit::NumberPages { .. } => {
if p.assemble() {
@@ -49,6 +50,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
| Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
@@ -454,6 +456,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?,
Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?,
Edit::ImportBookmarks { parent, index, bookmarks, replace } => {
pdfcraft_organize::import_outline(doc, parent, *index, bookmarks, *replace)?;
}
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
@@ -509,6 +514,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?;
}
Edit::AddModel3d { page, rect, model, poster, activation, name } => {
// A file that is not a model is refused before its poster is brought in.
pdfcraft_annot::check_model(model)?;
let poster = match poster {
Some(file) => Some(model_poster(doc, *rect, file)?),
None => None,
};
let new = pdfcraft_annot::NewModel3d { page: *page, rect: *rect, model, activation: *activation, poster, contents: name };
pdfcraft_annot::add_3d_model(doc, &new, &cx.meta())?;
}
Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?,
Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
Edit::ReplyToAnnotation { page, index, text, author } => {
+1
View File
@@ -54,6 +54,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
| Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
+8 -4
View File
@@ -71,7 +71,10 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::{CommentLayer, StampPreview};
pub use text_preview::{TextEditPreview, UnmarkedPages};
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
pub use pdfcraft_organize::{
BoxSpec, FieldConflicts, MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name,
split_ranges, unmapped as outline_unmapped, view_arity as outline_view_arity,
};
pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::Standard;
@@ -108,9 +111,10 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::{
AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps,
ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook,
Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
};
pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;
pub use pdfcraft_preflight as pdfa;
@@ -140,7 +144,7 @@ use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
pub use open::{OpenContext, PreparedDocument};
pub use page_filter::{PageOrientation, PageParity, filter_pages};
pub use pictures::{MarkFile, image_dimensions};
use pictures::{embed_image, fit_picture, mark_source};
use pictures::{embed_image, fit_picture, mark_source, model_poster};
use protection::unrestricted;
pub use protection::{Changes, Printing, Protection, SecuritySummary};
pub use session::Session;
+12
View File
@@ -60,6 +60,18 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
}
}
/// A 3D model's poster: the picture in `file` brought into `doc` and fitted inside `rect`
/// (user space), keeping its shape, and centred. It is drawn in user space and not counter-rotated
/// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does.
pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result<pdfcraft_annot::PosterPicture, EditError> {
let src = mark_source(doc, file)?;
let (sw, sh) = src.size;
if !(sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0) {
return Err(EditError::Source(format!("{}: the poster has no area", file.name)));
}
Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) })
}
/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster.
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let format = image::guess_format(bytes).ok()?;
+8
View File
@@ -168,6 +168,14 @@ impl Session {
Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more })
}
/// The bookmark at `path` with everything under it, or the whole outline for `[]`, as a tree
/// another document can take ([`crate::Edit::ImportBookmarks`]). `None` when no document has
/// `id`, or its structure could not be read for editing.
pub fn outline_tree(&self, id: DocId, path: &[usize]) -> Option<Result<pdfcraft_organize::OutlineTree, pdfcraft_organize::OutlineError>> {
let editor = self.get(id)?.editor.as_ref()?;
Some(pdfcraft_organize::outline_tree(&editor.cos, path))
}
/// Top-level bookmarks as split points: (first page of each part, its bookmark's title).
pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> {
let Some(doc) = self.get(id) else { return Vec::new() };
+111
View File
@@ -549,6 +549,85 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() {
assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}");
}
/// #1297: a 3D model on a page keeps the whole box as its rectangle and draws a PDF poster as a
/// form XObject fitted inside it, keeping its shape and centred.
#[test]
fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused() {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let blank = session.create_blank(595.0, 842.0, 1).unwrap();
let id = session.open_new("model.pdf", blank).unwrap();
let mut prc = b"PRC".to_vec();
prc.extend_from_slice(&[1; 64]);
let add = |model: Vec<u8>| Edit::AddModel3d {
page: 0,
rect: [56.0, 400.0, 539.0, 700.0],
model: Arc::new(model),
poster: Some(MarkFile { name: "poster.pdf".into(), bytes: Arc::new(half_red_page()), page: 0 }),
activation: Activation3d::default(),
name: "Bracket".into(),
};
assert!(session.apply(id, add(b"%PDF-1.7 not a model".to_vec())).is_err());
session.apply(id, add(prc)).unwrap();
let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap();
let page = &pdfcraft_model::pages(&doc)[0];
let annots = doc.resolve(page.dict.get(b"Annots").unwrap());
let [only] = annots.as_array().unwrap().as_slice() else { panic!("the refused model added nothing: {annots:?}") };
let annot = doc.get(only.as_ref().unwrap());
let annot = annot.as_dict().unwrap();
assert_eq!(annot.name(b"Subtype"), Some(&b"3D"[..]));
let rect: Vec<f64> = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect();
assert_eq!(rect, [56.0, 400.0, 539.0, 700.0], "the whole box, not the fitted picture");
assert_eq!(doc.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().name(b"A"), Some(&b"XA"[..]));
let ap = doc.resolve(annot.get(b"AP").unwrap());
let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else {
panic!("an appearance stream")
};
let pic = doc.resolve(normal.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
let pic = doc.resolve(pic.get(b"XObject").unwrap()).as_dict().unwrap().reference(b"Pic").unwrap();
let pdfcraft_cos::Object::Stream(picture) = doc.get(pic).as_ref().clone() else { panic!("the picture is a stream") };
assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject");
// The 80 × 40 page in the 483 × 300 box: 483 × 241.5 from y 429.25.
let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap();
assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}");
}
/// A PDF poster whose page has no area is refused rather than drawn as nothing.
#[test]
fn a_3d_poster_page_without_area_is_refused() {
let mut session = Session::new().with_clock(|| 1_700_000_000);
let blank = session.create_blank(595.0, 842.0, 1).unwrap();
let id = session.open_new("model.pdf", blank).unwrap();
let flat = String::from_utf8(half_red_page()).unwrap().replace("[0 0 80 40]", "[0 0 80 0 ]");
let add = Edit::AddModel3d {
page: 0,
rect: [56.0, 400.0, 539.0, 700.0],
model: Arc::new(b"PRC rest".to_vec()),
poster: Some(MarkFile { name: "flat.pdf".into(), bytes: Arc::new(flat.into_bytes()), page: 0 }),
activation: Activation3d::default(),
name: "Bracket".into(),
};
assert!(matches!(session.apply(id, add), Err(EditError::Source(_))));
assert!(session.get(id).unwrap().info.annotations.is_empty(), "nothing was added");
}
/// Adding a 3D model is a comment change: refused without the comments permission, and only the
/// comments are rebuilt after it.
#[test]
fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() {
let edit = Edit::AddModel3d {
page: 0,
rect: [0.0, 0.0, 100.0, 100.0],
model: Arc::new(b"PRC rest".to_vec()),
poster: None,
activation: Activation3d::default(),
name: String::new(),
};
let p = pdfcraft_cos::Permissions { bits: 0, owner: false };
assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments")));
assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments);
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
@@ -10369,3 +10448,35 @@ fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines");
assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical");
}
/// A whole tree goes in as one undoable step, and a refused tree changes nothing.
#[test]
fn imported_bookmarks_undo_in_one_step() {
let (mut s, id) = session_with(2);
let node = |title: &str, page, children| pdfcraft_organize::OutlineNode {
title: title.into(),
page,
view: None,
color: None,
italic: false,
bold: false,
open: true,
children,
};
let bookmarks = vec![node("Intro", Some(0), vec![node("Detail", Some(5), vec![])]), node("End", Some(1), vec![])];
s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Import bookmarks"));
assert_eq!(s.outline_tree(id, &[]).unwrap().unwrap().nodes[0].title, "Intro");
s.undo(id).unwrap();
assert!(s.get(id).unwrap().info.outline.is_empty());
s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap();
s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: vec![node("New", Some(1), vec![])], replace: true }).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["New→2"]);
s.undo(id).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]);
s.undo(id).unwrap();
let err = s.apply(id, Edit::ImportBookmarks { parent: vec![4], index: 0, bookmarks, replace: false }).unwrap_err();
assert!(err.to_string().contains("no bookmark"), "{err}");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
+3
View File
@@ -234,6 +234,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
let mut list_picked = None;
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
let parent_default = ancestor_sets(doc, f.obj, b"DV");
let parent_limit = ancestor_sets(doc, f.obj, b"MaxLen");
doc.update_dict(f.obj, |d| {
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
if let Some(t) = &props.tooltip {
@@ -246,6 +247,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(m) = props.max_len {
match m {
Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)),
// An absent key would inherit the parent's limit; zero is "no limit" (`read.rs`).
None if parent_limit => d.set(b"MaxLen".to_vec(), Object::Int(0)),
None => {
d.remove(b"MaxLen");
}
+23
View File
@@ -5487,6 +5487,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
}
#[test]
fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() {
let mut doc = fixture();
let city = field(&fields(&doc), "address.city").clone();
let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap();
set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap();
assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8));
set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
let mut doc = reopen(&doc);
let f = field(&fields(&doc), "address.city").clone();
assert_eq!(f.max_len, None, "no limit, not the parent's 3");
let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int);
assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own");
set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap();
assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters");
// Without a parent limit, clearing still removes the key.
set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap();
set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
let name = field(&fields(&doc), "name").clone();
assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none());
}
#[test]
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
let mut doc = fixture();
+2
View File
@@ -20,6 +20,7 @@ mod dedupe;
mod import;
mod labels;
mod outline;
mod outline_io;
mod pdfx;
mod prune;
mod transition;
@@ -40,6 +41,7 @@ pub use outline::{
Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark,
move_bookmark, rename_bookmark, set_bookmark_color, set_bookmark_open, set_bookmark_page, set_bookmark_style, top_level_bookmarks,
};
pub use outline_io::{MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, import_outline, outline_tree, unmapped, view_arity};
pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition};
pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
+12 -4
View File
@@ -19,7 +19,7 @@ use crate::{OrganizeError, walk};
/// Deepest level a listing enters (the top level is 0). Bookmarks deeper than this are not
/// listed; edits still reach them by path.
const LIST_DEPTH: usize = 32;
pub(crate) const LIST_DEPTH: usize = 32;
/// Deepest name-tree node read when looking up named destinations.
const NAME_TREE_DEPTH: usize = 32;
@@ -72,6 +72,14 @@ pub enum OutlineError {
BadColor,
#[error("this document has no tagged headings to make bookmarks from")]
NoEntries,
#[error("there are no bookmarks to add")]
NothingToImport,
#[error("the bookmark at {0:?} has a view that is not a known destination type with its values")]
BadView(Vec<usize>),
#[error("bookmarks may nest at most {0} levels deep")]
TooDeep(usize),
#[error("at most {0} bookmarks can be added at once")]
TooManyBookmarks(usize),
#[error("{0}")]
Organize(#[from] OrganizeError),
#[error("{0}")]
@@ -449,7 +457,7 @@ impl From<OutlineError> for OrganizeError {
/// Make `kids` the children of `parent`, in order: rewires `/Parent`, `/Prev`, `/Next`,
/// `/First` and `/Last`.
fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
pub(crate) fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
for (i, k) in kids.iter().enumerate() {
put(doc, *k, b"Parent", Some(Object::Ref(parent)))?;
put(doc, *k, b"Prev", i.checked_sub(1).map(|p| Object::Ref(kids[p])))?;
@@ -473,7 +481,7 @@ struct Counting {
/// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items
/// the negative of what opening them would show; the root counts all visible items).
fn recount(doc: &mut Document) -> Result<()> {
pub(crate) fn recount(doc: &mut Document) -> Result<()> {
let Some(root) = outline_root(doc) else { return Ok(()) };
let mut seen = HashSet::from([root]);
let kids = children_of(doc, root, &mut seen);
@@ -714,7 +722,7 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in
/// Open `parent` so a bookmark just added or moved into it shows (the root is always open).
/// Only the sign of `/Count` matters here: [`recount`] sets its value.
fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> {
pub(crate) fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> {
if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) {
put(doc, parent, b"Count", Some(Object::Int(1)))?;
}
+261
View File
@@ -0,0 +1,261 @@
//! Moving a whole outline (or one bookmark's subtree) out of a document and into another
//! (ISO 32000-2 §12.3.3 outlines, §12.3.2.2 explicit destinations).
//!
//! [`outline_tree`] reads bookmarks as a portable tree of [`OutlineNode`]s: titles, nesting, open
//! state, colour, style and destinations as page index plus view in the file's own user space,
//! so writing a tree back with [`import_outline`] gives the same structure. A node whose page the
//! target lacks keeps its place in the tree without a destination, and [`unmapped`] names it, so
//! nothing is dropped silently. Not carried: actions other than a GoTo, named destinations (they
//! travel resolved to their page), and structure links (`/SE`).
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use crate::outline::{LIST_DEPTH, children, container, recount, relink, show_children};
use crate::{OrganizeError, OutlineError, walk};
/// Most bookmarks one import writes.
pub const MAX_IMPORT_BOOKMARKS: usize = 100_000;
/// The destination types (Table 149) and how many numbers follow each.
const VIEWS: [(&[u8], usize); 8] = [(b"XYZ", 3), (b"Fit", 0), (b"FitH", 1), (b"FitV", 1), (b"FitR", 4), (b"FitB", 0), (b"FitBH", 1), (b"FitBV", 1)];
/// One bookmark and the bookmarks under it, independent of any document.
#[derive(Clone, Debug, PartialEq)]
pub struct OutlineNode {
pub title: String,
/// The 0-based page it goes to; `None` for a bookmark that goes nowhere in its document.
pub page: Option<usize>,
/// Where on the page: a destination type from Table 149 and exactly as many values as that
/// type takes, in PDF user space (`None` keeps the reader's value). `None` goes to the page at
/// the reader's position and zoom.
pub view: Option<(Vec<u8>, Vec<Option<f64>>)>,
/// The title's colour (`/C`), each component from 0 to 1; `None` is the default black.
pub color: Option<[f32; 3]>,
pub italic: bool,
pub bold: bool,
/// Shown expanded, when it has children.
pub open: bool,
pub children: Vec<OutlineNode>,
}
/// The tree [`outline_tree`] read.
#[derive(Clone, Debug, PartialEq)]
pub struct OutlineTree {
pub nodes: Vec<OutlineNode>,
/// Bookmarks nested deeper than the listing reaches (32 levels) were left out.
pub truncated: bool,
}
/// The bookmark at `path` with everything under it, or the whole outline for `[]`.
pub fn outline_tree(doc: &Document, path: &[usize]) -> Result<OutlineTree, OutlineError> {
// A whole outline's top level sits at depth 0; a subtree's root is the bookmark at `path`.
let base = path.len().max(1);
let mut truncated = false;
// The open nodes, outermost first, each with its depth.
let mut open: Vec<(usize, OutlineNode)> = Vec::new();
let mut roots = Vec::new();
for b in crate::bookmarks(doc).into_iter().filter(|b| b.path.starts_with(path) && !b.path.is_empty()) {
let depth = b.path.len().saturating_sub(base);
if b.path.len() > LIST_DEPTH && has_children(doc, b.obj) {
truncated = true;
}
while open.last().is_some_and(|(d, _)| *d >= depth) {
close(&mut open, &mut roots);
}
let view = b.view.and_then(|(kind, values)| portable_view(kind, values));
let node =
OutlineNode { title: b.title, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold, open: b.open, children: Vec::new() };
open.push((depth, node));
}
while !open.is_empty() {
close(&mut open, &mut roots);
}
if !path.is_empty() && roots.is_empty() {
return Err(OutlineError::NoSuchBookmark(path.to_vec()));
}
Ok(OutlineTree { nodes: roots, truncated })
}
fn close(open: &mut Vec<(usize, OutlineNode)>, roots: &mut Vec<OutlineNode>) {
if let Some((_, node)) = open.pop() {
match open.last_mut() {
Some((_, parent)) => parent.children.push(node),
None => roots.push(node),
}
}
}
fn has_children(doc: &Document, r: ObjRef) -> bool {
doc.get(r).as_dict().is_some_and(|d| d.reference(b"First").is_some())
}
/// A destination's view as [`import_outline`] takes it: a known type with exactly its number of
/// values (extra ones dropped, missing ones `None`). An unknown type, or a FitR missing one of
/// its four values (the rectangle can't be kept), gives `None`.
fn portable_view(kind: Vec<u8>, mut values: Vec<Option<f64>>) -> Option<(Vec<u8>, Vec<Option<f64>>)> {
let (_, arity) = VIEWS.iter().find(|(k, _)| *k == kind.as_slice())?;
values.resize(*arity, None);
if kind.as_slice() == b"FitR" && values.iter().any(Option::is_none) {
return None;
}
Some((kind, values))
}
/// The nodes of `nodes` whose page `page_count` pages don't have, as paths into `nodes`
/// (0-based child indices), in tree order. [`import_outline`] keeps these without a destination.
pub fn unmapped(nodes: &[OutlineNode], page_count: usize) -> Vec<Vec<usize>> {
let mut out = Vec::new();
visit(nodes, |path, node| {
if node.page.is_some_and(|p| p >= page_count) {
out.push(path.to_vec());
}
});
out
}
/// How many values a view of destination type `kind` takes (Table 149), or `None` for a type
/// that is not one of them.
pub fn view_arity(kind: &[u8]) -> Option<usize> {
VIEWS.iter().find(|(k, _)| *k == kind).map(|(_, arity)| *arity)
}
/// Call `f` with each node's path and the node, parents first. Iterative.
fn visit<'a>(nodes: &'a [OutlineNode], mut f: impl FnMut(&[usize], &'a OutlineNode)) {
// One entry per open level: its nodes and how many of them have been visited.
let mut stack: Vec<(&'a [OutlineNode], usize)> = vec![(nodes, 0)];
// The visited node's path, kept in step with the stack rather than rebuilt per node.
let mut path: Vec<usize> = Vec::new();
while let Some(level) = stack.last_mut() {
let list = level.0;
let index = level.1;
let Some(node) = list.get(index) else {
stack.pop();
continue;
};
level.1 = index.saturating_add(1);
path.truncate(stack.len().saturating_sub(1));
path.push(index);
f(&path, node);
if !node.children.is_empty() {
stack.push((&node.children, 0));
}
}
}
/// Check every node before anything is written: depth (counting the `base` levels above the
/// tree), count, colour and view.
fn check(nodes: &[OutlineNode], base: usize) -> Result<(), OutlineError> {
let mut problem = None;
let mut count = 0usize;
visit(nodes, |path, node| {
count = count.saturating_add(1);
if problem.is_some() {
return;
}
if base.saturating_add(path.len()) > LIST_DEPTH.saturating_add(1) {
problem = Some(OutlineError::TooDeep(LIST_DEPTH.saturating_add(1)));
} else if node.color.is_some_and(|c| !c.iter().all(|v| (0.0..=1.0).contains(v))) {
problem = Some(OutlineError::BadColor);
} else if node.view.as_ref().is_some_and(|v| !view_is_valid(v)) {
problem = Some(OutlineError::BadView(path.to_vec()));
}
});
if count > MAX_IMPORT_BOOKMARKS {
return Err(OutlineError::TooManyBookmarks(MAX_IMPORT_BOOKMARKS));
}
problem.map_or(Ok(()), Err)
}
/// A known type with its number of values, each finite; `/FitR` needs all four.
fn view_is_valid((kind, values): &(Vec<u8>, Vec<Option<f64>>)) -> bool {
VIEWS.iter().any(|(k, arity)| *k == kind.as_slice() && *arity == values.len())
&& values.iter().flatten().all(|v| v.is_finite())
&& (kind.as_slice() != b"FitR" || values.iter().all(Option::is_some))
}
/// Write `nodes` as children of the bookmark at `parent_path` (`[]` = top level), starting at
/// child `index` (past the end appends), or, with `replace`, in place of the outline's
/// top-level bookmarks (then `parent_path` must be `[]`). Nodes whose page this document lacks
/// are written without a destination (see [`unmapped`]). Everything is checked before anything
/// is written. Returns the path of the first node written.
pub fn import_outline(
doc: &mut Document,
parent_path: &[usize],
index: usize,
nodes: &[OutlineNode],
replace: bool,
) -> Result<Vec<usize>, OutlineError> {
if nodes.is_empty() {
return Err(OutlineError::NothingToImport);
}
if replace && !parent_path.is_empty() {
return Err(OrganizeError::Invalid("replace applies to the whole outline: leave the parent out".into()).into());
}
check(nodes, parent_path.len())?;
let pages: Vec<ObjRef> = walk(doc)?.into_iter().map(|(r, _)| r).collect();
let parent = container(doc, parent_path)?;
let top = write_nodes(doc, &pages, nodes)?;
let mut kids = if replace { Vec::new() } else { children(doc, parent) };
let at = index.min(kids.len());
kids.splice(at..at, top);
relink(doc, parent, &kids)?;
show_children(doc, parent, parent_path)?;
recount(doc)?;
let mut path = parent_path.to_vec();
path.push(at);
Ok(path)
}
/// Add one item per node, linked under each other, and return the top-level items in order.
fn write_nodes(doc: &mut Document, pages: &[ObjRef], nodes: &[OutlineNode]) -> Result<Vec<ObjRef>, OutlineError> {
// Each written item with its node, then its children's items once they are written.
let mut written: Vec<(ObjRef, Vec<ObjRef>)> = Vec::new();
let mut top = Vec::new();
// (the nodes of one level, the next to write, the index in `written` of their parent)
let mut stack: Vec<(&[OutlineNode], usize, Option<usize>)> = vec![(nodes, 0, None)];
while let Some(level) = stack.last_mut() {
let (list, parent) = (level.0, level.2);
let Some(node) = list.get(level.1) else {
stack.pop();
continue;
};
level.1 = level.1.saturating_add(1);
let item = doc.add(Object::Dict(item_dict(node, pages)));
match parent.and_then(|p| written.get_mut(p)) {
Some((_, kids)) => kids.push(item),
None => top.push(item),
}
written.push((item, Vec::new()));
if !node.children.is_empty() {
stack.push((&node.children, 0, Some(written.len().saturating_sub(1))));
}
}
for (item, kids) in &written {
relink(doc, *item, kids)?;
}
Ok(top)
}
/// The item's own entries; the links and `/Count`'s value come from [`relink`] and [`recount`]
/// (only the sign of the `/Count` written here matters: it keeps the open state).
fn item_dict(node: &OutlineNode, pages: &[ObjRef]) -> Dict {
let mut d = Dict::new();
d.set(b"Title".to_vec(), Object::String(PdfString::text(&node.title)));
if let Some(page) = node.page.and_then(|p| pages.get(p)) {
let (kind, values) = node.view.clone().unwrap_or_else(|| (b"XYZ".to_vec(), vec![None; 3]));
let mut dest = vec![Object::Ref(*page), Object::Name(kind)];
dest.extend(values.into_iter().map(|v| v.map_or(Object::Null, Object::Real)));
d.set(b"Dest".to_vec(), Object::Array(dest));
}
if let Some(c) = node.color.filter(|c| *c != [0.0; 3]) {
d.set(b"C".to_vec(), Object::Array(c.iter().map(|v| Object::Real(f64::from(*v))).collect()));
}
let flags = i64::from(node.italic) | i64::from(node.bold) << 1;
if flags != 0 {
d.set(b"F".to_vec(), Object::Int(flags));
}
if !node.children.is_empty() {
d.set(b"Count".to_vec(), Object::Int(if node.open { 1 } else { -1 }));
}
d
}
+172
View File
@@ -3813,3 +3813,175 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() {
ctrl_names.sort();
assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]);
}
/// Intro (closed, red bold, at a point) > [Part (italic, FitH), Figure (FitR)], then Results.
fn outline_source() -> Document {
let mut d = doc_a();
crate::add_bookmark_at(&mut d, &[], 0, "Intro", 0, Some(20.0), Some(380.0)).unwrap();
crate::add_bookmark(&mut d, &[0], 0, "Part", 1).unwrap();
crate::add_bookmark(&mut d, &[0], 1, "Figure", 2).unwrap();
crate::add_bookmark(&mut d, &[], 1, "Results", 2).unwrap();
crate::set_bookmark_color(&mut d, &[0], Some([1.0, 0.0, 0.0])).unwrap();
crate::set_bookmark_style(&mut d, &[0], Some(true), None).unwrap();
crate::set_bookmark_style(&mut d, &[0, 0], None, Some(true)).unwrap();
let pages = crate::walk(&d).unwrap();
let (part, figure) = (crate::bookmarks(&d)[1].obj, crate::bookmarks(&d)[2].obj);
let dest = |p: usize, rest: Vec<Object>| Object::Array([vec![Object::Ref(pages[p].0)], rest].concat());
d.update_dict(part, |x| x.set(b"Dest".to_vec(), dest(1, vec![Object::name("FitH"), Object::Real(300.0)]))).unwrap();
let fit_r = vec![Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110), Object::Int(220)];
d.update_dict(figure, |x| x.set(b"Dest".to_vec(), dest(2, fit_r))).unwrap();
crate::set_bookmark_open(&mut d, &[0], false).unwrap();
d
}
fn node(title: &str, page: Option<usize>, children: Vec<crate::OutlineNode>) -> crate::OutlineNode {
crate::OutlineNode { title: title.into(), page, view: None, color: None, italic: false, bold: false, open: true, children }
}
/// The `/Prev`, `/Last` and `/Parent` links agree with `/First`–`/Next` at every level.
fn assert_back_links(doc: &Document) {
let root = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
let mut stack = vec![root];
while let Some(parent) = stack.pop() {
let kids = crate::outline::children(doc, parent);
let d = doc.get(parent).as_dict().cloned().unwrap();
assert_eq!(d.reference(b"Last"), kids.last().copied());
for (i, k) in kids.iter().enumerate() {
let kd = doc.get(*k).as_dict().cloned().unwrap();
assert_eq!(kd.reference(b"Prev"), i.checked_sub(1).map(|p| kids[p]));
assert_eq!(kd.reference(b"Parent"), Some(parent));
}
stack.extend(kids);
}
}
#[test]
fn an_exported_outline_imports_into_another_document_unchanged() {
let source = outline_source();
let tree = crate::outline_tree(&source, &[]).unwrap();
assert!(!tree.truncated);
let intro = &tree.nodes[0];
assert_eq!((intro.title.as_str(), intro.page, intro.open, intro.bold, intro.color), ("Intro", Some(0), false, true, Some([1.0, 0.0, 0.0])));
assert_eq!(intro.view, Some((b"XYZ".to_vec(), vec![Some(20.0), Some(380.0), None])));
assert_eq!(intro.children[0].view, Some((b"FitH".to_vec(), vec![Some(300.0)])));
assert!(intro.children[0].italic);
assert_eq!(intro.children[1].view, Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), Some(220.0)])));
let mut target = doc_a();
assert_eq!(crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(), vec![0]);
assert_back_links(&target);
let back = full_roundtrip(&target);
assert_eq!(crate::outline_tree(&back, &[]).unwrap(), tree);
// The closed parent stays closed: its count is negative and the root counts only what shows.
let first = crate::bookmarks(&back)[0].obj;
let root = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
assert_eq!((count_of(&back, first), count_of(&back, root)), (Some(-2), Some(2)));
}
#[test]
fn a_subtree_exports_alone_and_a_missing_path_is_refused() {
let source = outline_source();
let tree = crate::outline_tree(&source, &[0]).unwrap();
assert_eq!(tree.nodes.len(), 1);
assert_eq!(tree.nodes[0].title, "Intro");
assert_eq!(tree.nodes[0].children.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), ["Part", "Figure"]);
let leaf = crate::outline_tree(&source, &[0, 1]).unwrap();
assert_eq!((leaf.nodes.len(), leaf.nodes[0].title.as_str()), (1, "Figure"));
assert_eq!(crate::outline_tree(&source, &[5]), Err(crate::OutlineError::NoSuchBookmark(vec![5])));
assert_eq!(crate::outline_tree(&doc_a(), &[]).unwrap().nodes, []);
}
#[test]
fn a_page_the_target_lacks_keeps_its_bookmark_without_a_destination() {
let tree = crate::outline_tree(&outline_source(), &[]).unwrap();
// doc_b has two pages: Figure and Results go to the third.
assert_eq!(crate::unmapped(&tree.nodes, 2), [vec![0, 1], vec![1]]);
let mut target = doc_b();
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
let listed = crate::bookmarks(&target);
assert_eq!(titles(&listed), ["Intro[Part,Figure]", "Results"]);
assert_eq!(listed.iter().map(|b| b.page).collect::<Vec<_>>(), [Some(0), Some(1), None, None]);
assert!(target.get(listed[2].obj).as_dict().unwrap().get(b"Dest").is_none());
}
#[test]
fn a_fit_r_missing_a_value_copies_with_its_page_and_no_view() {
let mut src = outline_source();
let pages = crate::walk(&src).unwrap();
let figure = crate::bookmarks(&src)[2].obj;
let short = vec![Object::Ref(pages[2].0), Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110)];
src.update_dict(figure, |x| x.set(b"Dest".to_vec(), Object::Array(short))).unwrap();
let tree = crate::outline_tree(&src, &[]).unwrap();
let copied = &tree.nodes[0].children[1];
assert_eq!((copied.page, copied.view.clone()), (Some(2), None));
let mut target = doc_a();
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
assert_eq!(titles(&crate::bookmarks(&target)), ["Intro[Part,Figure]", "Results"]);
}
#[test]
fn an_import_appends_inserts_or_replaces() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "Old A", 0).unwrap();
crate::add_bookmark(&mut d, &[], 1, "Old B", 1).unwrap();
let new = [node("New", Some(2), vec![node("Child", Some(1), vec![])])];
assert_eq!(crate::import_outline(&mut d, &[], 1, &new, false).unwrap(), vec![1]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B"]);
assert_eq!(crate::import_outline(&mut d, &[2], 9, &new, false).unwrap(), vec![2, 0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B[New[Child]]"]);
assert_back_links(&d);
assert_eq!(crate::import_outline(&mut d, &[], 5, &new, true).unwrap(), vec![0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["New[Child]"]);
assert_back_links(&d);
let refused = crate::import_outline(&mut d, &[0], 0, &new, true);
assert!(matches!(refused, Err(crate::OutlineError::Organize(crate::OrganizeError::Invalid(_)))));
assert_eq!(crate::import_outline(&mut d, &[], 0, &[], false), Err(crate::OutlineError::NothingToImport));
assert_eq!(crate::import_outline(&mut d, &[7], 0, &new, false), Err(crate::OutlineError::NoSuchBookmark(vec![7])));
}
#[test]
fn a_bad_node_anywhere_refuses_the_whole_import() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "Kept", 0).unwrap();
let before = crate::bookmarks(&d);
let with = |f: &dyn Fn(&mut crate::OutlineNode)| {
let mut child = node("Child", Some(0), vec![]);
f(&mut child);
vec![node("Fine", Some(0), vec![]), node("Parent", Some(0), vec![child])]
};
let bad_views: [(&[u8], Vec<Option<f64>>); 4] =
[(b"XYZ", vec![Some(1.0)]), (b"Zoom", vec![]), (b"FitH", vec![Some(f64::NAN)]), (b"FitR", vec![Some(0.0), None, Some(1.0), Some(1.0)])];
for (kind, values) in bad_views {
let nodes = with(&|n| n.view = Some((kind.to_vec(), values.clone())));
assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadView(vec![1, 0])), "{kind:?}");
}
let nodes = with(&|n| n.color = Some([0.5, 1.5, 0.0]));
assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor));
let nodes = with(&|n| n.color = Some([f32::NAN, 0.0, 0.0]));
assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor));
let mut deep = node("Leaf", None, vec![]);
for _ in 0..33 {
deep = node("Level", None, vec![deep]);
}
assert_eq!(crate::import_outline(&mut d, &[], 0, &[deep], false), Err(crate::OutlineError::TooDeep(33)));
// 33 levels fit at the top, but not under a bookmark: the deepest would be past the listing.
let mut chain = node("Leaf", None, vec![]);
for _ in 0..32 {
chain = node("Level", None, vec![chain]);
}
assert_eq!(crate::import_outline(&mut d, &[0], 0, std::slice::from_ref(&chain), false), Err(crate::OutlineError::TooDeep(33)));
let many = vec![node("Item", None, vec![]); crate::MAX_IMPORT_BOOKMARKS + 1];
assert_eq!(crate::import_outline(&mut d, &[], 0, &many, false), Err(crate::OutlineError::TooManyBookmarks(crate::MAX_IMPORT_BOOKMARKS)));
assert_eq!(crate::bookmarks(&d), before);
}
#[test]
fn an_outline_deeper_than_the_listing_is_exported_as_truncated() {
let tree = crate::outline_tree(&open(deep_outline(40)), &[]).unwrap();
assert!(tree.truncated);
assert!(!crate::outline_tree(&open(deep_outline(33)), &[]).unwrap().truncated);
// What was exported imports back: 33 levels is the deepest an import takes.
let mut target = doc_a();
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
assert_eq!(crate::bookmarks(&target).len(), 33);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pdfcraft-prc"
description = "PRC 3D model reader (ISO 14739-1): container, bit-level primitives, schema"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
flate2.workspace = true
thiserror.workspace = true
[dev-dependencies]
proptest.workspace = true
+43
View File
@@ -0,0 +1,43 @@
# pdfcraft-prc
A reader for PRC (ISO 14739-1), the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries (ISO 32000-2 §13.6.3).
- **Layer:** L0, standalone. No dependency on any other PdfCraft crate.
- **Licence:** MIT OR Apache-2.0. Inflation through `flate2` with the pure-Rust `miniz_oxide` backend.
- `#![forbid(unsafe_code)]`. Never panics on input: inflated bytes, decoded elements, schema steps and walk depth are capped, and damage returns `PrcError`.
## API
```rust
PrcFile::parse(data) -> Result<PrcFile, PrcError> // header, file structures, model file
PrcFile::parse_with_limit(data, max_inflated) -> Result<PrcFile, PrcError>
FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry
FileStructure::schema() -> Result<Schema, PrcError> // the globals section's leading schema
FileStructure::tessellations() -> Result<Vec<Tessellation>, PrcError> // meshes, wires, markups, compressed meshes
PrcFile::placements() / placements_with(StyleAlpha, EntityOverrides) -> Result<Vec<Placement>, PrcError>
PrcFile::model_tree() -> Result<Vec<ModelNode>, PrcError> // the assembly tree as a model-tree panel lists it
Placement::triangle_colours(&TriangleMesh) -> Option<Vec<Option<Rgba>>>
TriangleMesh::transformed(&Matrix) -> TriangleMesh
Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader)
BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double
```
- `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit.
- `MAX_DECODED_ELEMENTS` (2^24) bounds the array elements one pass decodes (a tree walk, or one `tessellations` call), since a single bit can decode to a 4-8 byte element; `TooManyElements` reports it.
- Schema programs (how a newer producer describes fields it added) run under a nesting ceiling (64) and `MAX_SCHEMA_STEPS` (2^22), shared like `MAX_DECODED_ELEMENTS` by every program one pass runs.
- `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`).
- `Tessellation::Mesh` is a `TriangleMesh`: positions, triangles, one triangle range per face, the stored normals and
texture coordinates with each triangle's corner indices into them, and per-triangle line-attribute styles.
`Wire` is polylines; `Markup` is read past.
- `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet.
- A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`.
- The product tree: each drawable tessellation placed by its occurrence path's locations and the item's coordinate
systems (a 4x4 `Matrix`), coloured by the style inheritance rules, with occurrences' entity references applied
(`EntityOverrides`: subtree, everywhere or ignored) and style transparency combined with material alpha by `StyleAlpha`.
A prototype's part is coloured by its own file structure's styles. Each `ModelNode` lists its name, where the name
came from, its visibility and the range of placements it drew.
- Occurrence and prototype cycles are `Malformed`; the walk is capped at depth 64, 2^20 visits and `MAX_PLACEMENTS`.
- A tree's markups (PMI), views, scene lights and clipping planes are read past, not modelled; so are textures.
- Not yet in the crate: rebuilding compressed meshes, textures.
- The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`.
A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source.
+93
View File
@@ -0,0 +1,93 @@
//! The prefix-code table behind the PRC `Double` [WD 11.17.2].
//!
//! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent
//! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to.
//! The rows come from prc-rs under its MIT licence (see [`table`]).
use std::collections::HashMap;
use std::sync::OnceLock;
mod table;
/// Rows a complete table has.
const ROWS: usize = 2077;
/// The longest code, in bits.
pub(crate) const MAX_CODE_BITS: u32 = 22;
/// What a code decodes to.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Entry {
/// A frequent value, returned with the sign bit applied.
Double(f64),
/// The upper 32 bits of a binary64 (exponent set, mantissa zero); the mantissa follows
/// in the stream.
Exponent(u32),
}
/// One row: code length, code, and what it decodes to.
type Row = (u32, u32, Entry);
fn key(nbits: u32, code: u32) -> u32 {
(nbits << 24) | code
}
/// The rows keyed by `(nbits, code)`, or `None` when the table is incomplete or not prefix
/// free (then no `Double` decodes, rather than some decoding wrongly).
fn build(rows: &[Row]) -> Option<HashMap<u32, Entry>> {
let mut map = HashMap::with_capacity(rows.len());
for &(nbits, code, entry) in rows {
if nbits == 0 || nbits > MAX_CODE_BITS || code >> nbits != 0 || map.insert(key(nbits, code), entry).is_some() {
return None;
}
}
let prefix_free = map.keys().all(|&k| {
let (n, c) = (k >> 24, k & 0xff_ffff);
(1..n).all(|m| !map.contains_key(&key(m, c >> (n - m))))
});
(map.len() == ROWS && prefix_free).then_some(map)
}
fn table() -> Option<&'static HashMap<u32, Entry>> {
static MAP: OnceLock<Option<HashMap<u32, Entry>>> = OnceLock::new();
MAP.get_or_init(|| build(table::TABLE)).as_ref()
}
/// The entry for an `nbits`-long code, if one exists.
pub(crate) fn lookup(nbits: u32, code: u32) -> Option<Entry> {
table()?.get(&key(nbits, code)).copied()
}
/// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`].
#[cfg(test)]
pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> {
table()?.iter().find(|(_, v)| matches!(v, Entry::Exponent(hi) if (hi >> 20) & 0x7ff == e)).map(|(&k, _)| (k >> 24, k & 0xff_ffff))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn an_incomplete_or_ambiguous_table_decodes_nothing() {
assert!(build(&[(2, 0b01, Entry::Double(0.0))]).is_none(), "one row is not the table");
let rows: Vec<Row> = (0..ROWS as u32).map(|i| (22, i, Entry::Exponent(i))).collect();
assert!(build(&rows).is_some(), "2077 distinct 22-bit codes are prefix free");
let mut ambiguous = rows.clone();
ambiguous[0] = (1, 0, Entry::Double(1.0)); // a prefix of every code starting 0
assert!(build(&ambiguous).is_none());
let mut too_long = rows;
too_long[0] = (2, 0b111, Entry::Double(1.0)); // a code wider than its length
assert!(build(&too_long).is_none());
}
#[test]
fn the_table_is_complete() {
let t = table().unwrap();
assert_eq!(t.len(), ROWS);
assert_eq!(t.values().filter(|e| matches!(e, Entry::Double(_))).count(), 29);
assert!((0..2048).all(|e| exponent_code(e).is_some()));
assert_eq!(lookup(2, 0b01), Some(Entry::Double(0.0)));
}
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
//! The array primitives of compressed tessellation [WD 11.6-11.9] and the Huffman payload
//! they may carry [WD 12.2].
//!
//! The WD gives only the Huffman writer. The reader decodes what producers write: each
//! leaf's code length is read in the width the payload's 8-bit field states (the WD writes a
//! fixed 8 bits), stored codes carry a leading root bit, and the payload is read LSB-first
//! although the stream around it is MSB-first.
use std::collections::HashMap;
use crate::PrcError;
use crate::bits::BitReader;
/// Longest Huffman code [WD 12.2].
const MAX_CODE_BITS: u32 = 32;
/// Widest Huffman symbol a caller asks for: the arrays hold 2- to 16-bit values.
const MAX_SYMBOL_BITS: u32 = 16;
fn malformed(what: &str) -> PrcError {
PrcError::Malformed(what.to_owned())
}
/// An LSB-first cursor over a Huffman payload.
struct Lsb<'a> {
data: &'a [u8],
pos: usize,
end: usize,
}
impl Lsb<'_> {
fn bit(&mut self) -> Result<u32, PrcError> {
if self.pos >= self.end {
return Err(PrcError::Truncated("a Huffman payload"));
}
let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a Huffman payload"))?;
let b = u32::from((byte >> (self.pos % 8)) & 1);
self.pos += 1;
Ok(b)
}
/// `n` ≤ 32 bits, the first read being the least significant.
fn bits(&mut self, n: u32) -> Result<u32, PrcError> {
let mut v = 0u32;
for i in 0..n.min(32) {
v |= self.bit()? << i;
}
Ok(v)
}
fn remaining(&self) -> usize {
self.end.saturating_sub(self.pos)
}
}
/// The low `n` (1..=16) bits of `v` as a symbol, two's complement when `signed`.
fn symbol(v: u32, n: u32, signed: bool) -> i32 {
let v = i64::from(v);
let wide = if signed && n > 0 && v & (1 << (n - 1)) != 0 { v - (1i64 << n) } else { v };
// n <= 16, so `wide` is within ±2^16.
i32::try_from(wide).unwrap_or(0)
}
/// The Huffman container of a compressed Character/Short array [WD 11.6, 11.7, 12.2]:
/// symbols are `nbits` wide, sign-extended when `signed`.
fn huffman(r: &mut BitReader<'_>, nbits: u32, signed: bool) -> Result<Vec<i32>, PrcError> {
if nbits == 0 || nbits > MAX_SYMBOL_BITS {
return Err(malformed("Huffman symbol width"));
}
let words = r.unsigned_integer()? as usize;
if words == 0 {
return Ok(Vec::new());
}
if words.saturating_mul(32) > r.remaining() {
return Err(PrcError::Truncated("a Huffman payload"));
}
// words * 32 bits remain, so words * 4 bytes cannot overflow.
let blob = (0..words * 4).map(|_| r.character()).collect::<Result<Vec<u8>, _>>()?;
let last = r.unsigned_integer()?;
if last > 32 {
return Err(malformed("Huffman last-word bit count above 32"));
}
let end = (words - 1) * 32 + last as usize;
let mut b = Lsb { data: &blob, pos: 0, end };
let codes = huffman_leaves(&mut b, nbits, signed)?;
if codes.is_empty() {
return Ok(Vec::new());
}
let count = b.bits(32)? as usize;
// Every element is at least one bit.
if count > b.remaining() {
return Err(PrcError::Truncated("a Huffman element count"));
}
r.take_elements(count)?;
let mut out = Vec::with_capacity(count.min(1024));
for _ in 0..count {
out.push(huffman_symbol(&mut b, &codes)?);
}
Ok(out)
}
/// The leaf table: `(code length, stored code)` to symbol.
fn huffman_leaves(b: &mut Lsb<'_>, nbits: u32, signed: bool) -> Result<HashMap<(u32, u32), i32>, PrcError> {
let leaves = b.bits(nbits + 1)?;
if leaves == 0 {
return Ok(HashMap::new());
}
// prc-rs: the code-length width is read from the payload, not fixed at 8 bits.
let len_width = b.bits(8)?;
if len_width == 0 || len_width > MAX_CODE_BITS {
return Err(malformed("Huffman code-length width"));
}
// Every leaf is at least nbits + 2 bits, so the payload bounds the table.
if (leaves as usize).saturating_mul((nbits + 2) as usize) > b.remaining() {
return Err(PrcError::Truncated("a Huffman payload"));
}
let mut codes = HashMap::new();
for _ in 0..leaves {
let s = symbol(b.bits(nbits)?, nbits, signed);
let len = b.bits(len_width)?;
if len == 0 || len > MAX_CODE_BITS {
return Err(malformed("Huffman code length"));
}
let code = b.bits(len)?;
if codes.insert((len, code), s).is_some() {
return Err(malformed("duplicate Huffman code"));
}
}
// A leaf whose code begins another's would end that code early, leaving its unread bits
// in front of the next element.
let shadowed = codes.keys().any(|&(len, code)| (1..len).any(|l| codes.contains_key(&(l, code >> (len - l)))));
if shadowed {
return Err(malformed("a Huffman code that is a prefix of another"));
}
Ok(codes)
}
/// One element: code bits, most significant first, until a leaf matches.
fn huffman_symbol(b: &mut Lsb<'_>, codes: &HashMap<(u32, u32), i32>) -> Result<i32, PrcError> {
// prc-rs: the stored code's first bit read is its most significant (the root bit).
let (mut code, mut len) = (0u32, 0u32);
while len < MAX_CODE_BITS {
code = (code << 1) | b.bit()?;
len += 1;
if let Some(&s) = codes.get(&(len, code)) {
return Ok(s);
}
}
Err(malformed("a Huffman code matching no leaf"))
}
/// A `CharacterArray(nbits)` [WD 11.6]. `compressed` is `None` when the flag is written,
/// else the caller's implicit value. Uncompressed elements are whole bytes, sign-extended
/// from 8 bits when `signed`.
pub(crate) fn character_array(r: &mut BitReader<'_>, nbits: u32, compressed: Option<bool>, signed: bool) -> Result<Vec<i32>, PrcError> {
let compressed = match compressed {
Some(c) => c,
None => r.bit()?,
};
if compressed {
return huffman(r, nbits, signed);
}
let n = r.unsigned_integer()? as usize;
if n.saturating_mul(8) > r.remaining() {
return Err(PrcError::Truncated("a character array"));
}
r.take_elements(n)?;
(0..n)
.map(|_| {
let c = r.character()?;
Ok(if signed { i32::from(c.cast_signed()) } else { i32::from(c) })
})
.collect()
}
/// A `ShortArray(nbits)` [WD 11.7]: uncompressed elements are a low then a high byte, read
/// unsigned.
pub(crate) fn short_array(r: &mut BitReader<'_>, nbits: u32) -> Result<Vec<i32>, PrcError> {
if r.bit()? {
return huffman(r, nbits, false);
}
let n = r.unsigned_integer()? as usize;
if n.saturating_mul(16) > r.remaining() {
return Err(PrcError::Truncated("a short array"));
}
r.take_elements(n)?;
(0..n)
.map(|_| {
let lo = r.character()?;
let hi = r.character()?;
Ok(i32::from(u16::from_le_bytes([lo, hi])))
})
.collect()
}
/// A `CompressedIntegerArray` [WD 11.8]: a CharacterArray(6) of widths, then one
/// `IntegerWithVariableBitNumber` per width.
pub(crate) fn compressed_integer_array(r: &mut BitReader<'_>) -> Result<Vec<i64>, PrcError> {
let widths = character_array(r, 6, None, false)?;
widths
.into_iter()
.map(|w| {
let w = u32::try_from(w).map_err(|_| malformed("integer width"))?;
r.integer_vbn(w)
})
.collect()
}
/// A `CompressedIndiceArray` [WD 11.9], read as producers write it: the stored signed 6-bit
/// values are width differences summed into each element's width, and each element is a
/// delta from the previous (the WD's own reading desyncs from the third element). Indices
/// are never negative.
pub(crate) fn compressed_indice_array(r: &mut BitReader<'_>, compressed: Option<bool>) -> Result<Vec<u32>, PrcError> {
let diffs = character_array(r, 6, compressed, true)?;
let mut out = Vec::with_capacity(diffs.len());
let (mut width, mut prev) = (0i64, 0i64);
for d in diffs {
// prc-rs: widths are running sums of the stored differences, values running deltas.
width = width.checked_add(i64::from(d)).ok_or_else(|| malformed("indice width"))?;
let w = u32::try_from(width).map_err(|_| malformed("indice width"))?;
let v = prev.checked_add(r.integer_vbn(w)?).ok_or_else(|| malformed("indice delta"))?;
out.push(u32::try_from(v).map_err(|_| malformed("an index outside 0..2^32"))?);
prev = v;
}
Ok(out)
}
/// An `UncompressedBoolArray` of `n` raw bits (no length prefix).
pub(crate) fn bool_array(r: &mut BitReader<'_>, n: usize) -> Result<Vec<bool>, PrcError> {
if n > r.remaining() {
return Err(PrcError::Truncated("a boolean array"));
}
r.take_elements(n)?;
(0..n).map(|_| r.bit()).collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::testw::W;
fn read<T>(w: &W, f: impl FnOnce(&mut BitReader<'_>) -> Result<T, PrcError>) -> T {
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let v = f(&mut r).unwrap();
assert_eq!(r.position(), w.len(), "bits consumed");
v
}
#[test]
fn huffman_decodes_the_wd_example_with_root_bits() {
// WD 12.2's input {1,3,5,7,7,9,5,3,3}; codes carry the root bit.
let leaves = [(3, 0b100, 3), (7, 0b101, 3), (5, 0b110, 3), (1, 0b1110, 4), (9, 0b1111, 4)];
let values = [1, 3, 5, 7, 7, 9, 5, 3, 3];
let mut w = W::default();
w.bit(true).huffman(4, 3, &leaves, &values);
assert_eq!(read(&w, |r| character_array(r, 4, None, false)), values);
}
#[test]
fn huffman_symbols_sign_extend_only_when_signed() {
let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)];
let mut w = W::default();
w.huffman(6, 2, &leaves, &[62, 1, 62]);
assert_eq!(read(&w, |r| huffman(r, 6, true)), [-2, 1, -2]);
assert_eq!(read(&w, |r| huffman(r, 6, false)), [62, 1, 62]);
}
#[test]
fn huffman_rejects_codes_that_match_no_leaf_and_empty_payloads_are_empty() {
let mut bad = W::default();
bad.huffman_raw(2, 2, &[(1, 0b10, 2)], &[(0b11, 2)]);
let bytes = bad.bytes();
assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Malformed(_) | PrcError::Truncated(_))));
let mut empty = W::default();
empty.uint(0);
assert!(read(&empty, |r| huffman(r, 2, false)).is_empty());
}
#[test]
fn a_huffman_element_count_past_the_payload_is_truncated_not_allocated() {
// One leaf, then an element count of 2^32 - 1 with no elements behind it.
let mut w = W::default();
w.huffman_count(2, 2, &[(1, 0b10, 2)], u32::MAX);
let bytes = w.bytes();
assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Truncated("a Huffman element count"))));
}
#[test]
fn a_huffman_code_that_begins_another_is_malformed() {
// `10` would end every `100` one bit early.
let mut w = W::default();
w.huffman(2, 2, &[(1, 0b10, 2), (2, 0b100, 3)], &[2, 1]);
let bytes = w.bytes();
let r = huffman(&mut BitReader::new(&bytes), 2, false);
assert!(malformed_by(r, "a Huffman code that is a prefix of another"));
}
#[test]
fn huffman_widths_out_of_range_are_errors() {
// Well-formed payloads at each width, which only the width check refuses.
for nbits in [0, 17] {
let mut w = W::default();
w.huffman(nbits, 2, &[(1, 0b10, 2)], &[1]);
let bytes = w.bytes();
assert!(matches!(huffman(&mut BitReader::new(&bytes), nbits, false), Err(PrcError::Malformed(_))), "{nbits}");
}
}
#[test]
fn uncompressed_arrays() {
let mut w = W::default();
w.bit(false).uint(2).put(0xfe, 8).put(3, 8);
assert_eq!(read(&w, |r| character_array(r, 6, None, true)), [-2, 3]);
let mut s = W::default();
s.bit(false).uint(1).put(0x34, 8).put(0x12, 8);
assert_eq!(read(&s, |r| short_array(r, 16)), [0x1234]);
}
#[test]
fn integer_array_reads_one_width_per_value() {
let mut w = W::default();
w.bit(false).uint(3).put(3, 8).put(0, 8).put(4, 8);
w.bit(true).put(3, 2); // -3 in 3 bits
w.bit(false).put(5, 3); // 5 in 4 bits
assert_eq!(read(&w, compressed_integer_array), [-3, 0, 5]);
}
#[test]
fn indice_array_sums_widths_and_deltas() {
// Values 5, 6, 4: widths 4, 2, 3 stored as diffs 4, -2, +1.
let mut w = W::default();
w.bit(false).uint(3).put(4, 8).put(0xfe, 8).put(1, 8);
w.bit(false).put(5, 3); // 5
w.bit(false).put(1, 1); // +1
w.bit(true).put(2, 2); // -2
assert_eq!(read(&w, |r| compressed_indice_array(r, None)), [5, 6, 4]);
// The implicit-flag form reads no flag bit.
let mut i = W::default();
i.uint(1).put(2, 8).bit(false).put(1, 1);
assert_eq!(read(&i, |r| compressed_indice_array(r, Some(false))), [1]);
// A negative running index is damage.
let mut n = W::default();
n.bit(false).uint(1).put(2, 8).bit(true).put(1, 1);
let bytes = n.bytes();
assert!(compressed_indice_array(&mut BitReader::new(&bytes), None).is_err());
}
#[test]
fn a_bool_array_longer_than_the_data_is_truncated() {
assert!(matches!(bool_array(&mut BitReader::new(&[0]), 9), Err(PrcError::Truncated(_))));
assert_eq!(bool_array(&mut BitReader::new(&[0x80]), 2).unwrap(), [true, false]);
}
#[test]
fn decoded_elements_are_charged_against_one_allowance() {
// One bit can decode to a 4-byte element, so the byte cap alone does not bound memory.
let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)];
let mut w = W::default();
w.huffman(6, 2, &leaves, &[62, 1, 62]);
let bytes = w.bytes();
let mut r = BitReader::with_elements(&bytes, 2);
assert!(matches!(huffman(&mut r, 6, false), Err(PrcError::TooManyElements { .. })));
let mut r = BitReader::with_elements(&bytes, 4);
assert_eq!(huffman(&mut r, 6, false).unwrap(), [62, 1, 62]);
assert_eq!(r.elements_left(), 1);
assert!(matches!(bool_array(&mut BitReader::with_elements(&[0], 1), 2), Err(PrcError::TooManyElements { .. })));
}
/// Uncompressed character and short arrays are charged against the allowance too, and a
/// character count past the data is refused by name before anything is read.
#[test]
fn uncompressed_arrays_are_charged_and_their_counts_checked() {
let mut w = W::default();
w.uint(3).put(0, 8).put(1, 8).put(2, 8);
let chars = w.bytes();
let read = |n| character_array(&mut BitReader::with_elements(&chars, n), 8, Some(false), false);
assert!(matches!(read(2), Err(PrcError::TooManyElements { .. })));
assert_eq!(read(3).unwrap(), [0, 1, 2]);
let mut w = W::default();
w.bit(false).uint(2).put(1, 8).put(0, 8).put(2, 8).put(0, 8);
let shorts = w.bytes();
assert!(matches!(short_array(&mut BitReader::with_elements(&shorts, 1), 8), Err(PrcError::TooManyElements { .. })));
assert_eq!(short_array(&mut BitReader::with_elements(&shorts, 2), 8).unwrap(), [1, 2]);
let mut w = W::default();
w.uint(1000).put(7, 8);
let short_of_data = w.bytes();
let res = character_array(&mut BitReader::new(&short_of_data), 8, Some(false), false);
assert!(matches!(res, Err(PrcError::Truncated("a character array"))), "{res:?}");
}
fn malformed_by(res: Result<Vec<i32>, PrcError>, what: &str) -> bool {
matches!(res, Err(PrcError::Malformed(m)) if m == what)
}
#[test]
fn a_zero_code_length_width_or_length_is_refused_by_name() {
let mut w = W::default();
w.huffman_raw(2, 0, &[(1, 0, 0)], &[]);
let bytes = w.bytes();
assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code-length width"));
let mut w = W::default();
w.huffman_raw(2, 2, &[(1, 0, 0)], &[]);
let bytes = w.bytes();
assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code length"));
}
#[test]
fn a_boolean_array_past_the_data_is_refused_before_it_is_read() {
let mut w = W::default();
w.put(0b1011, 4);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let n = r.remaining() + 1;
assert_eq!(bool_array(&mut r, n), Err(PrcError::Truncated("a boolean array")));
assert_eq!(r.position(), 0, "nothing read");
}
}
+491
View File
@@ -0,0 +1,491 @@
//! The bit-level reader for PRC compressed sections [WD 10.3, 11.1-11.17].
//!
//! Values are consumed MSB-first from each inflated byte and straddle byte boundaries freely
//! [WD 10.3.1, 11.3]. Multi-byte integers are groups of eight bits, low group first. Two sign
//! conventions coexist: `Integer` is two's complement, the `*WithVariableBitNumber` types are
//! sign-magnitude.
use crate::PrcError;
use crate::acof::{self, Entry};
/// The most array elements the readers of one PRC stream decode together (2^24): at most 8
/// bytes each, so 128 MiB.
pub const MAX_DECODED_ELEMENTS: usize = 1 << 24;
/// The most schema-program steps (tokens executed, loop iterations included) the readers of
/// one PRC stream run together.
pub const MAX_SCHEMA_STEPS: u64 = 1 << 22;
/// What the readers of one stream may still spend, carried from each reader to the next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Budget {
/// Array elements still to decode.
pub(crate) elements: usize,
/// Schema-program steps still to run.
pub(crate) steps: u64,
}
impl Budget {
/// A whole stream's allowance.
pub(crate) const FULL: Self = Self { elements: MAX_DECODED_ELEMENTS, steps: MAX_SCHEMA_STEPS };
}
/// A cursor over one inflated section.
#[derive(Debug, Clone)]
pub struct BitReader<'a> {
data: &'a [u8],
pos: usize,
budget: Budget,
}
impl<'a> BitReader<'a> {
/// A reader positioned at the first bit of `data`, allowed [`MAX_DECODED_ELEMENTS`] and
/// [`MAX_SCHEMA_STEPS`].
pub fn new(data: &'a [u8]) -> Self {
Self::with_budget(data, Budget::FULL)
}
/// A reader of `data` allowed `budget`: what an earlier reader of the same stream left.
pub(crate) fn with_budget(data: &'a [u8], budget: Budget) -> Self {
Self { data, pos: 0, budget }
}
/// A reader of `data` allowed `elements` array elements and a full step allowance.
#[cfg(test)]
pub(crate) fn with_elements(data: &'a [u8], elements: usize) -> Self {
Self::with_budget(data, Budget { elements, ..Budget::FULL })
}
/// This reader, at its position, allowed `budget` instead: what readers of the same
/// stream left since it stopped.
pub(crate) fn with_remaining(mut self, budget: Budget) -> Self {
self.budget = budget;
self
}
/// What this reader may still spend.
pub(crate) fn budget(&self) -> Budget {
self.budget
}
/// Array elements this reader may still decode.
#[cfg(test)]
pub(crate) fn elements_left(&self) -> usize {
self.budget.elements
}
/// Charge `n` decoded array elements against the allowance.
///
/// # Errors
/// [`PrcError::TooManyElements`] when fewer than `n` are left.
pub(crate) fn take_elements(&mut self, n: usize) -> Result<(), PrcError> {
self.budget.elements = self.budget.elements.checked_sub(n).ok_or(PrcError::TooManyElements { limit: MAX_DECODED_ELEMENTS })?;
Ok(())
}
/// Charge one schema-program step; false when none is left.
pub(crate) fn take_step(&mut self) -> bool {
match self.budget.steps.checked_sub(1) {
Some(left) => {
self.budget.steps = left;
true
}
None => false,
}
}
/// Bits consumed so far.
pub fn position(&self) -> usize {
self.pos
}
/// Bits left before the end of the data.
pub fn remaining(&self) -> usize {
self.data.len().saturating_mul(8).saturating_sub(self.pos)
}
/// Step over `n` bits of opaque data (`UserData` [WD 8.6]).
///
/// # Errors
/// [`PrcError::Truncated`] when fewer than `n` bits remain; the cursor does not move.
pub fn skip_bits(&mut self, n: usize) -> Result<(), PrcError> {
if n > self.remaining() {
return Err(PrcError::Truncated("user data"));
}
self.pos = self.pos.saturating_add(n);
Ok(())
}
/// One bit; a PRC `Boolean` [WD 10.3.3].
///
/// # Errors
/// [`PrcError::Truncated`] at the end of the data.
pub fn bit(&mut self) -> Result<bool, PrcError> {
let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a bit"))?;
let b = (byte >> (7 - (self.pos % 8))) & 1 == 1;
// `pos / 8` indexed the data, so `pos + 1` is at most `8 * len`.
self.pos = self.pos.saturating_add(1);
Ok(b)
}
/// `n` bits (at most 32), MSB-first; `UnsignedIntegerWithVariableBitNumber` [WD 11.13].
///
/// # Errors
/// [`PrcError::Truncated`] at the end of the data; [`PrcError::Malformed`] for `n > 32`.
pub fn bits(&mut self, n: u32) -> Result<u32, PrcError> {
if n > 32 {
return Err(PrcError::Malformed(format!("{n}-bit field")));
}
if self.remaining() < n as usize {
return Err(PrcError::Truncated("a bit field"));
}
let mut v: u64 = 0;
for _ in 0..n {
v = (v << 1) | u64::from(self.bit()?);
}
// n <= 32, so v fits.
Ok(u32::try_from(v).unwrap_or(u32::MAX))
}
fn byte(&mut self) -> Result<u8, PrcError> {
Ok(u8::try_from(self.bits(8)?).unwrap_or(u8::MAX))
}
/// A `Character`: one byte [WD 10.3.4].
///
/// # Errors
/// [`PrcError::Truncated`].
pub fn character(&mut self) -> Result<u8, PrcError> {
self.byte()
}
/// An `UnsignedInteger` [WD 11.10]: while a 1 bit precedes it, one more eight-bit group,
/// low group first.
///
/// # Errors
/// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups.
pub fn unsigned_integer(&mut self) -> Result<u32, PrcError> {
let mut v: u32 = 0;
let mut shift = 0;
while self.bit()? {
if shift >= 32 {
return Err(PrcError::Malformed("UnsignedInteger over 32 bits".into()));
}
v |= u32::from(self.byte()?) << shift;
shift += 8;
}
Ok(v)
}
/// An `Integer` [WD 11.11]: a 0 bit is zero; otherwise eight-bit groups, low group first,
/// each followed by a continue bit, sign-extended from the last group's top bit.
///
/// # Errors
/// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups.
pub fn integer(&mut self) -> Result<i32, PrcError> {
if !self.bit()? {
return Ok(0);
}
let mut v: u32 = 0;
let mut shift = 0;
loop {
if shift >= 32 {
return Err(PrcError::Malformed("Integer over 32 bits".into()));
}
let group = self.byte()?;
v |= u32::from(group) << shift;
shift += 8;
if !self.bit()? {
if group & 0x80 != 0 && shift < 32 {
v |= u32::MAX << shift;
}
return Ok(v.cast_signed());
}
}
}
/// An `IntegerWithVariableBitNumber(n)` [WD 11.12]: a negative flag (1 = negative; the WD
/// prose says the opposite, its sample code and real files agree on this), then the
/// magnitude in `n - 1` bits.
///
/// # Errors
/// [`PrcError::Truncated`]; [`PrcError::Malformed`] for `n > 33`.
pub fn integer_vbn(&mut self, n: u32) -> Result<i64, PrcError> {
let Some(magnitude_bits) = n.checked_sub(1) else {
return Ok(0);
};
let negative = self.bit()?;
let m = i64::from(self.bits(magnitude_bits)?);
Ok(if negative { -m } else { m })
}
/// A `DoubleWithVariableBitNumber(tolerance, n)` [WD 11.14]: a sign-magnitude quantum
/// count times `tolerance`.
///
/// # Errors
/// As [`Self::integer_vbn`].
pub fn double_vbn(&mut self, tolerance: f64, n: u32) -> Result<f64, PrcError> {
Ok(self.integer_vbn(n)? as f64 * tolerance)
}
/// `NumberOfBitsThenUnsignedInteger` [WD 11.15]: a 5-bit width, then the value in that
/// many bits.
///
/// # Errors
/// [`PrcError::Truncated`].
pub fn nbits_then_unsigned(&mut self) -> Result<u32, PrcError> {
let n = self.bits(5)?;
self.bits(n)
}
/// A `String` [WD 10.3.7, 11.4]: `None` for the null string, else UTF-8 (invalid
/// sequences replaced).
///
/// # Errors
/// [`PrcError::Truncated`] when the byte count runs past the data.
pub fn string(&mut self) -> Result<Option<String>, PrcError> {
if !self.bit()? {
return Ok(None);
}
let len = self.unsigned_integer()? as usize;
if len.saturating_mul(8) > self.remaining() {
return Err(PrcError::Truncated("a String"));
}
let bytes = (0..len).map(|_| self.byte()).collect::<Result<Vec<u8>, _>>()?;
Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
}
/// `FloatAsBytes` [WD 11.5]: an IEEE binary32, least-significant byte first.
///
/// # Errors
/// [`PrcError::Truncated`].
pub fn float_as_bytes(&mut self) -> Result<f32, PrcError> {
let b = [self.byte()?, self.byte()?, self.byte()?, self.byte()?];
Ok(f32::from_le_bytes(b))
}
/// A `Double` [WD 11.17]: a prefix code from the Double table, then a sign bit and, for
/// an exponent code, a byte-wise mantissa with back-references.
///
/// The two-bit zero code has no sign bit. Back-reference offsets 1 to 5 are all accepted,
/// distance 1 included (PDF Association pdf-issues #769).
///
/// # Errors
/// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`]
/// for a back-reference outside the value.
pub fn double(&mut self) -> Result<f64, PrcError> {
// prc-rs: the decoding steps (code lookup, sign, exponent, byte-wise mantissa with
// back-references) follow prc-rs's.
let mut code = 0u32;
let mut entry = None;
for n in 1..=acof::MAX_CODE_BITS {
code = (code << 1) | u32::from(self.bit()?);
if let Some(e) = acof::lookup(n, code) {
if n == 2 && code == 0b01 {
return Ok(0.0);
}
entry = Some(e);
break;
}
}
let entry = entry.ok_or(PrcError::UnknownDoubleCode)?;
let negative = self.bit()?;
let upper = match entry {
Entry::Double(v) => return Ok(if negative { -v } else { v }),
Entry::Exponent(upper) => upper,
};
let mut b = (u64::from(upper) << 32).to_le_bytes();
if self.bit()? {
self.mantissa(&mut b)?;
}
let sign = if negative { 1u64 << 63 } else { 0 };
Ok(f64::from_bits(u64::from_le_bytes(b) | sign))
}
/// Mantissa bits 51..0 into `b` (little-endian byte order): a nibble, then bytes 5 down
/// to 0, each a literal or a back-reference to a byte already set.
fn mantissa(&mut self, b: &mut [u8; 8]) -> Result<(), PrcError> {
let nibble = u8::try_from(self.bits(4)?).unwrap_or(0);
let [.., b6, _] = b;
*b6 |= nibble;
let at = |b: &[u8; 8], i: usize| b.get(i).copied().ok_or_else(|| PrcError::Malformed("Double back-reference past the value".into()));
for cbi in (0..=5usize).rev() {
let prev = at(b, cbi + 1)?;
let value = if self.bit()? {
self.byte()?
} else {
match self.bits(3)? {
// Fill this byte and every lower one with the previous byte.
0 => {
b.iter_mut().take(cbi + 1).for_each(|x| *x = prev);
return Ok(());
}
// As 0, but the lowest byte is a literal.
6 => {
b.iter_mut().take(cbi + 1).skip(1).for_each(|x| *x = prev);
let low = self.byte()?;
let [b0, ..] = b;
*b0 = low;
return Ok(());
}
off @ 1..=5 => at(b, cbi + off as usize)?,
_ => return Err(PrcError::Malformed("Double mantissa offset 7".into())),
}
};
if let Some(x) = b.get_mut(cbi) {
*x = value;
}
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::testw::W;
/// The code for IEEE exponent `e`.
fn exponent_code(e: u32) -> (u32, u32) {
acof::exponent_code(e).expect("the table has a row per exponent")
}
fn read_double(w: &W) -> f64 {
BitReader::new(&w.bytes()).double().unwrap()
}
#[test]
fn zero_is_the_two_bit_code_with_no_sign() {
let mut w = W::default();
w.put(0b01, 2).put(0b1111_1111, 8);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
assert_eq!(r.double().unwrap(), 0.0);
assert_eq!(r.position(), 2);
}
#[test]
fn exponent_with_literal_mantissa() {
let v = -1234.5678f64;
let le = v.to_bits().to_le_bytes();
let e = u32::try_from((v.to_bits() >> 52) & 0x7ff).unwrap();
let (n, c) = exponent_code(e);
let mut w = W::default();
w.put(u64::from(c), n).put(1, 1).put(1, 1).put(u64::from(le[6] & 0x0f), 4);
for byte in le[..6].iter().rev() {
w.put(1, 1).put(u64::from(*byte), 8);
}
assert_eq!(read_double(&w), v);
}
#[test]
fn exponent_without_mantissa_is_a_power_of_two() {
let (n, c) = exponent_code(1023 + 3);
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(0, 1);
assert_eq!(read_double(&w), 8.0);
}
#[test]
fn fill_back_reference_and_literal_low_byte() {
// Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11.
let (n, c) = exponent_code(1023);
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4);
w.put(1, 1).put(0xAA, 8);
w.put(0, 1).put(1, 3);
w.put(0, 1).put(2, 3);
w.put(0, 1).put(6, 3).put(0x11, 8);
assert_eq!(read_double(&w), f64::from_le_bytes([0x11, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xF5, 0x3F]));
// Byte 5 literal, then fill 4..0.
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4);
w.put(1, 1).put(0x77, 8).put(0, 1).put(0, 3);
assert_eq!(read_double(&w), f64::from_le_bytes([0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xF5, 0x3F]));
}
#[test]
fn a_back_reference_past_the_value_is_refused() {
let (n, c) = exponent_code(1023);
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0, 4);
w.put(0, 1).put(5, 3).put(0, 16); // byte 5 + offset 5 = byte 10
assert!(matches!(BitReader::new(&w.bytes()).double(), Err(PrcError::Malformed(_))));
}
#[test]
fn a_truncated_double_is_an_error_not_a_panic() {
// No code is 8 bits or shorter and a prefix of 0x17, so one byte ends inside a code.
assert!(matches!(BitReader::new(&[0x17]).double(), Err(PrcError::Truncated(_))));
assert!(matches!(BitReader::new(&[]).double(), Err(PrcError::Truncated(_))));
}
#[test]
fn integers_and_strings() {
let mut w = W::default();
// UnsignedInteger 300 = groups 0x2C, 0x01.
w.put(1, 1).put(0x2C, 8).put(1, 1).put(0x01, 8).put(0, 1);
// Integer -2 = group 0xFE, stop.
w.put(1, 1).put(0xFE, 8).put(0, 1);
// Integer 200 = 0xC8 then 0x00 (top bit clear), stop.
w.put(1, 1).put(0xC8, 8).put(1, 1).put(0x00, 8).put(0, 1);
// IntegerWithVariableBitNumber(4) = -5.
w.put(1, 1).put(5, 3);
// String "hé".
w.put(1, 1).put(1, 1).put(3, 8).put(0, 1).put(0x68, 8).put(0xC3, 8).put(0xA9, 8);
// Null string.
w.put(0, 1);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
assert_eq!(r.unsigned_integer().unwrap(), 300);
assert_eq!(r.integer().unwrap(), -2);
assert_eq!(r.integer().unwrap(), 200);
assert_eq!(r.integer_vbn(4).unwrap(), -5);
assert_eq!(r.string().unwrap().as_deref(), Some("hé"));
assert_eq!(r.string().unwrap(), None);
}
#[test]
fn the_writer_round_trips_through_the_reader() {
let mut w = W::default();
w.uint(0).uint(u32::MAX).int(-129).int(i32::MIN).int(i32::MAX).string(Some("glass")).string(None);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
assert_eq!(r.unsigned_integer().unwrap(), 0);
assert_eq!(r.unsigned_integer().unwrap(), u32::MAX);
assert_eq!(r.integer().unwrap(), -129);
assert_eq!(r.integer().unwrap(), i32::MIN);
assert_eq!(r.integer().unwrap(), i32::MAX);
assert_eq!(r.string().unwrap().as_deref(), Some("glass"));
assert_eq!(r.string().unwrap(), None);
}
#[test]
fn oversized_groups_and_widths_are_malformed() {
// Five UnsignedInteger groups.
let mut w = W::default();
(0..5).for_each(|_| {
w.put(1, 1).put(0xFF, 8);
});
w.put(0, 1);
assert!(matches!(BitReader::new(&w.bytes()).unsigned_integer(), Err(PrcError::Malformed(_))));
assert!(matches!(BitReader::new(&[0; 8]).bits(33), Err(PrcError::Malformed(_))));
assert!(matches!(BitReader::new(&[0; 8]).integer_vbn(34), Err(PrcError::Malformed(_))));
assert_eq!(BitReader::new(&[]).integer_vbn(0), Ok(0));
}
#[test]
fn truncation_is_an_error_not_a_panic() {
let mut r = BitReader::new(&[0xFF]);
assert!(r.unsigned_integer().is_err());
let mut r = BitReader::new(&[0xC0, 0x40]);
assert!(matches!(r.string(), Err(PrcError::Truncated(_))));
let mut r = BitReader::new(&[0xAB]);
assert!(matches!(r.skip_bits(9), Err(PrcError::Truncated(_))));
assert_eq!(r.position(), 0, "a refused skip does not move the cursor");
r.skip_bits(8).unwrap();
assert_eq!(r.remaining(), 0);
}
}
+340
View File
@@ -0,0 +1,340 @@
//! The PRC container: file header, file-structure descriptions, section extents and
//! inflation [WD 6.1-6.2, 10.2].
//!
//! Section extents are not stored: each section runs to the next one's start offset, and the
//! last one to the model file's start. Inflation stops at the zlib stream's end, so padding
//! between sections is tolerated.
use std::io::Read;
use crate::PrcError;
use crate::bits::BitReader;
use crate::schema::Schema;
/// The PRC version this reader implements.
pub const PRC_READER_VERSION: u32 = 8137;
/// The most file structures [`PrcFile::parse`] accepts.
pub const MAX_FILE_STRUCTURES: usize = 65_536;
/// Ceiling on the total inflated size of every section in one PRC stream, together with the
/// picture and uncompressed blocks it copies.
pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024;
/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`]
/// order.
const SECTIONS: usize = 6;
/// A 128-bit PRC identifier [WD 10.2.4]: four little-endian `u32`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct UniqueId(pub [u32; 4]);
/// The compressed sections of one file structure, in file order [WD 6.1.2, 6.2.1].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SectionKind {
/// Starts with the file structure's schema, then its globals entity.
Globals,
/// The product-structure tree.
Tree,
/// Tessellated (mesh) data.
Tessellation,
/// Exact (B-rep) geometry.
Geometry,
/// Supplementary geometry.
ExtraGeometry,
}
impl SectionKind {
/// Every kind, in file order.
pub const ALL: [SectionKind; 5] =
[SectionKind::Globals, SectionKind::Tree, SectionKind::Tessellation, SectionKind::Geometry, SectionKind::ExtraGeometry];
/// The section's name, as used in errors.
pub fn name(self) -> &'static str {
match self {
SectionKind::Globals => "globals",
SectionKind::Tree => "tree",
SectionKind::Tessellation => "tessellation",
SectionKind::Geometry => "geometry",
SectionKind::ExtraGeometry => "extra geometry",
}
}
fn index(self) -> usize {
match self {
SectionKind::Globals => 0,
SectionKind::Tree => 1,
SectionKind::Tessellation => 2,
SectionKind::Geometry => 3,
SectionKind::ExtraGeometry => 4,
}
}
}
/// The uncompressed file header [WD 6.1.1].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrcHeader {
/// The oldest reader version the producer says can read the file.
pub min_version_for_read: u32,
/// The producer's PRC version.
pub authoring_version: u32,
/// The file's identifier.
pub file_id: UniqueId,
/// The producing application's identifier (all zero = unregistered).
pub application_id: UniqueId,
}
/// One file structure: its uncompressed header fields and its five inflated sections
/// [WD 6.2].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileStructure {
/// The identifier; equals the file header's description of it.
pub id: UniqueId,
/// Per-structure reader version floor; may differ from the file's.
pub min_version_for_read: u32,
/// Per-structure producer version.
pub authoring_version: u32,
/// Picture payloads (JPEG, PNG, ...) carried uncompressed in the header, referenced by
/// index from picture entities [WD 6.2.2].
pub pictures: Vec<Vec<u8>>,
sections: [Vec<u8>; 5],
}
impl FileStructure {
/// The inflated bytes of one section.
pub fn section(&self, kind: SectionKind) -> &[u8] {
self.sections.get(kind.index()).map_or(&[], Vec::as_slice)
}
/// The globals section's leading schema: the fields producers newer than this reader
/// added to each entity type [WD 6.3].
///
/// # Errors
/// As [`Schema::read`].
pub fn schema(&self) -> Result<Schema, PrcError> {
Schema::read(&mut BitReader::new(self.section(SectionKind::Globals)))
}
}
/// A parsed PRC stream: header, file structures and model-file section.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrcFile {
/// The file header.
pub header: PrcHeader,
/// File structures in physical order (referenced before referencing).
pub file_structures: Vec<FileStructure>,
/// The inflated model-file section: its schema, then the model file entity.
pub model_file: Vec<u8>,
/// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture
/// entities may also reference.
pub uncompressed_files: Vec<Vec<u8>>,
}
/// A little-endian byte cursor over the uncompressed parts of the stream.
struct Bytes<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Bytes<'a> {
fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], PrcError> {
let end = self.pos.checked_add(n).ok_or(PrcError::Truncated(what))?;
let s = self.data.get(self.pos..end).ok_or(PrcError::Truncated(what))?;
self.pos = end;
Ok(s)
}
fn u32(&mut self, what: &'static str) -> Result<u32, PrcError> {
let s = self.take(4, what)?;
let b: [u8; 4] = s.try_into().map_err(|_| PrcError::Truncated(what))?;
Ok(u32::from_le_bytes(b))
}
fn id(&mut self, what: &'static str) -> Result<UniqueId, PrcError> {
Ok(UniqueId([self.u32(what)?, self.u32(what)?, self.u32(what)?, self.u32(what)?]))
}
fn magic(&mut self) -> Result<(), PrcError> {
match self.take(3, "magic") {
Ok(b"PRC") => Ok(()),
_ => Err(PrcError::NotPrc),
}
}
/// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3], each charged to
/// `budget` before it is copied.
fn blocks(&mut self, what: &'static str, budget: &mut Budget) -> Result<Vec<Vec<u8>>, PrcError> {
let n = offset(self.u32(what)?);
// Each block needs at least its 4-byte size, so `n` is bounded by the data left.
if n > self.data.len().saturating_sub(self.pos) / 4 {
return Err(PrcError::Truncated(what));
}
(0..n)
.map(|_| {
let len = offset(self.u32(what)?);
let block = self.take(len, what)?;
budget.charge(block.len())?;
Ok(block.to_vec())
})
.collect()
}
}
/// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data
/// that large could exist).
pub(crate) fn offset(v: u32) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
}
/// One file structure description of the file header [WD 6.2]: its id and the offsets of
/// its sections.
fn file_structure_description(r: &mut Bytes) -> Result<(UniqueId, Vec<usize>), PrcError> {
let id = r.id("file structure description")?;
if r.u32("file structure description")? != 0 {
return Err(PrcError::Malformed("file structure reserved field is not 0".into()));
}
let count = offset(r.u32("file structure description")?);
if count < SECTIONS {
return Err(PrcError::Malformed(format!("{count} sections in a file structure")));
}
let mut offs = Vec::with_capacity(SECTIONS);
// Offsets past the six this reader uses are read and dropped; each read is bounded by
// the data, so a huge `count` ends in `Truncated`.
for i in 0..count {
let o = r.u32("section offset")?;
if i < SECTIONS {
offs.push(offset(o));
}
}
Ok((id, offs))
}
/// Where one file structure's parts lie: its section offsets and the end of its last section.
struct Extent<'a> {
offs: &'a [usize],
last: usize,
}
impl Extent<'_> {
fn bound(&self, i: usize) -> usize {
self.offs.get(i).copied().unwrap_or(self.last)
}
}
/// The file structure `id` lying at `extent`: its header and its inflated sections, charged
/// to `budget`.
fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budget) -> Result<FileStructure, PrcError> {
if extent.offs.windows(2).any(|w| w.first() > w.get(1)) || extent.bound(SECTIONS - 1) > extent.last {
return Err(PrcError::Malformed("file structure section offsets are not ascending".into()));
}
let header = data.get(..extent.bound(1)).ok_or(PrcError::Truncated("file structure header"))?;
let mut h = Bytes { data: header, pos: extent.bound(0) };
h.magic()?;
let min_version_for_read = h.u32("file structure header")?;
let authoring_version = h.u32("file structure header")?;
if h.id("file structure header")? != id {
return Err(PrcError::Malformed("file structure id differs from its description".into()));
}
let _application = h.id("file structure header")?;
// Headers may overlap, so each structure's copy of its pictures is charged.
let pictures = h.blocks("file structure pictures", budget)?;
let mut sections: [Vec<u8>; 5] = Default::default();
for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() {
let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last };
*slot = budget.inflate(data, (extent.bound(i + 1), end), kind.name())?;
}
Ok(FileStructure { id, min_version_for_read, authoring_version, pictures, sections })
}
/// What is left of the inflation ceiling, and the ceiling itself for the error.
struct Budget {
left: usize,
limit: usize,
}
impl Budget {
/// The zlib stream in `data[range]`, its size charged to the budget.
fn inflate(&mut self, data: &[u8], range: (usize, usize), section: &'static str) -> Result<Vec<u8>, PrcError> {
let (start, end) = range;
if start == end {
return Ok(Vec::new());
}
let src = data.get(start..end).ok_or_else(|| PrcError::Malformed(format!("{section} section {start}..{end} is outside the data")))?;
let mut out = Vec::new();
// One byte past the budget is enough to know it was exceeded.
let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1);
flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?;
self.charge(out.len())?;
Ok(out)
}
/// Spend `n` bytes of the ceiling.
fn charge(&mut self, n: usize) -> Result<(), PrcError> {
self.left = self.left.checked_sub(n).ok_or(PrcError::TooLarge { limit: self.limit })?;
Ok(())
}
}
impl PrcFile {
/// Parse a PRC stream (the decoded data of a `/3D` stream with `/Subtype /PRC`).
///
/// Newer `min_version_for_read` values are not refused: the fields read here are
/// unchanged across published versions, and the caller sees the versions in
/// [`PrcHeader`].
///
/// # Errors
/// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or
/// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`]
/// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`]
/// in total, inflated sections and copied blocks together.
///
/// # Examples
/// ```
/// use pdfcraft_prc::{PrcError, PrcFile};
/// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc));
/// ```
pub fn parse(data: &[u8]) -> Result<Self, PrcError> {
Self::parse_with_limit(data, MAX_INFLATED_BYTES)
}
/// [`Self::parse`] with a caller-chosen ceiling on the total inflated size, for callers
/// with less memory to spend than [`MAX_INFLATED_BYTES`].
///
/// # Errors
/// As [`Self::parse`]; [`PrcError::TooLarge`] carries `limit`.
pub fn parse_with_limit(data: &[u8], limit: usize) -> Result<Self, PrcError> {
let mut r = Bytes { data, pos: 0 };
r.magic()?;
let header = PrcHeader {
min_version_for_read: r.u32("file header")?,
authoring_version: r.u32("file header")?,
file_id: r.id("file header")?,
application_id: r.id("file header")?,
};
let n_fs = offset(r.u32("file structure count")?);
if n_fs == 0 || n_fs > MAX_FILE_STRUCTURES {
return Err(PrcError::Malformed(format!("{n_fs} file structures")));
}
let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::<Result<Vec<_>, _>>()?;
let mf_start = offset(r.u32("model file offsets")?);
let mf_end = offset(r.u32("model file offsets")?);
let mut budget = Budget { left: limit, limit };
let uncompressed_files = r.blocks("uncompressed files", &mut budget)?;
if mf_end < mf_start {
return Err(PrcError::Malformed("model file ends before it starts".into()));
}
if mf_end > data.len() {
return Err(PrcError::Malformed(format!("model file ends at {mf_end}, past the {}-byte stream", data.len())));
}
// prc-rs: the last section runs to the model file's start.
let last = mf_start.min(data.len());
let file_structures =
descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::<Result<Vec<_>, _>>()?;
let model_file = budget.inflate(data, (mf_start, mf_end), "model file")?;
Ok(PrcFile { header, file_structures, model_file, uncompressed_files })
}
}
+127
View File
@@ -0,0 +1,127 @@
//! What every section reader shares: the cursor, the schema, the authoring version, and the
//! base fields every PRC entity starts with [WD 7.2.3, 7.4.3, 8.6].
use crate::PrcError;
use crate::bits::BitReader;
use crate::schema::Schema;
use crate::tree::Graphics;
const ATTRIBUTE: u32 = 201;
/// A [`PrcError::Malformed`] naming `what`.
pub(crate) fn malformed(what: String) -> PrcError {
PrcError::Malformed(what)
}
/// A section reader.
pub(crate) struct Ctx<'a, 's> {
pub(crate) r: BitReader<'a>,
pub(crate) schema: &'s Schema,
/// The file structure's authoring version; gates version-added fields.
pub(crate) version: u32,
/// The current name: what a `same_name` entity reuses; every entity's own name replaces
/// it [WD 7.2.3.4]. Each section starts with none.
pub(crate) name: Option<String>,
/// The PRC unique id of the last `ContentPRCRefBase` read [WD 7.2.3.2].
pub(crate) uid: u32,
/// The current graphics: what a `same_graphics` entity reuses [WD 7.2.4].
pub(crate) graphics: Graphics,
}
impl<'a, 's> Ctx<'a, 's> {
pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self {
Ctx { r, schema, version, name: None, uid: 0, graphics: Graphics::default() }
}
/// Reads an entity type code; any value but `want` is malformed.
pub(crate) fn expect_type(&mut self, want: u32) -> Result<(), PrcError> {
let t = self.r.unsigned_integer()?;
if t != want {
return Err(malformed(format!("entity type {t} where {want} belongs")));
}
Ok(())
}
/// A count that must fit in the remaining data at `min_bits` per item, which bounds any
/// allocation sized by it.
pub(crate) fn count(&mut self, min_bits: usize, what: &'static str) -> Result<usize, PrcError> {
let n = self.r.unsigned_integer()? as usize;
if n.saturating_mul(min_bits) > self.r.remaining() {
return Err(PrcError::Truncated(what));
}
self.r.take_elements(n)?;
Ok(n)
}
/// A counted array of `UnsignedInteger`s.
pub(crate) fn uints(&mut self, what: &'static str) -> Result<Vec<u32>, PrcError> {
let n = self.count(1, what)?;
(0..n).map(|_| self.r.unsigned_integer()).collect()
}
/// A counted array of `Double`s.
pub(crate) fn doubles(&mut self, what: &'static str) -> Result<Vec<f64>, PrcError> {
let n = self.count(2, what)?;
(0..n).map(|_| self.r.double()).collect()
}
/// `AttributeEntry` [WD 7.4.3]: a predefined title code or a string.
fn attribute_entry(&mut self) -> Result<(), PrcError> {
if self.r.bit()? {
self.r.unsigned_integer()?;
} else {
self.r.string()?;
}
Ok(())
}
/// One attribute value [WD 7.4.3], read past.
fn attribute_value(&mut self) -> Result<(), PrcError> {
match self.r.unsigned_integer()? {
0 => {}
1 | 3 => {
self.r.integer()?;
}
2 => {
self.r.double()?;
}
4 => {
self.r.string()?;
}
// prc-rs: a 64-bit integer is its high part, then its low part.
5 => {
self.r.integer()?;
self.r.unsigned_integer()?;
}
k => return Err(malformed(format!("attribute value kind {k}"))),
}
Ok(())
}
/// `ContentPRCBase` [WD 7.2.3]: attributes, then the name; returns the entity's name,
/// which is also the current name afterwards.
pub(crate) fn content_prc_base(&mut self) -> Result<Option<String>, PrcError> {
let n = self.count(1, "attributes")?;
for _ in 0..n {
self.expect_type(ATTRIBUTE)?;
self.attribute_entry()?;
let pairs = self.count(2, "attribute pairs")?;
for _ in 0..pairs {
self.attribute_entry()?;
self.attribute_value()?;
}
self.schema.skip_added_fields(ATTRIBUTE, &mut self.r)?;
}
// `same_name` TRUE reuses the current name [WD 7.2.3.4].
if !self.r.bit()? {
self.name = self.r.string()?;
}
Ok(self.name.clone())
}
/// `UserData` [WD 8.6]: a bit count, then that many opaque bits.
pub(crate) fn user_data(&mut self) -> Result<(), PrcError> {
let n = self.r.unsigned_integer()? as usize;
self.r.skip_bits(n)
}
}
+46
View File
@@ -0,0 +1,46 @@
//! The crate's error type.
/// Why a PRC stream could not be read.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PrcError {
/// The data does not start with the `PRC` magic [WD 6.1.1].
#[error("not a PRC stream: missing the \"PRC\" magic")]
NotPrc,
/// A value ran past the end of its byte or bit stream.
#[error("PRC data truncated while reading {0}")]
Truncated(&'static str),
/// A structural field holds a value the format forbids.
#[error("malformed PRC: {0}")]
Malformed(String),
/// No `Double` code of up to 22 bits matched [WD 11.17].
#[error("PRC Double: no code matches the next 22 bits")]
UnknownDoubleCode,
/// A section is not a valid zlib stream.
#[error("PRC {section} section does not inflate: {reason}")]
Inflate {
/// The section's name.
section: &'static str,
/// The decoder's message.
reason: String,
},
/// The inflated sections and copied blocks together exceed the ceiling
/// ([`crate::MAX_INFLATED_BYTES`] or the caller's limit).
#[error("PRC sections inflate past the {limit}-byte ceiling")]
TooLarge {
/// The ceiling that was hit.
limit: usize,
},
/// The arrays read from one stream hold more elements than [`crate::MAX_DECODED_ELEMENTS`].
/// One bit can decode to a 4- or 8-byte element, so the inflation ceiling alone does not
/// bound the memory the arrays take.
#[error("PRC arrays decode past the {limit}-element ceiling")]
TooManyElements {
/// The ceiling that was hit.
limit: usize,
},
/// A construct this reader does not decode; the part of the file holding it cannot be
/// read past.
#[error("PRC {0} is not supported")]
Unsupported(&'static str),
}
+276
View File
@@ -0,0 +1,276 @@
//! Model-space vectors and matrices, and their application to meshes.
use crate::TriangleMesh;
/// A point or direction in model space.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub(crate) struct Vec3(pub(crate) [f64; 3]);
impl Vec3 {
/// The right-handed cross product `self × b`.
pub(crate) fn cross(self, b: Vec3) -> Vec3 {
let ([ax, ay, az], [bx, by, bz]) = (self.0, b.0);
Vec3([ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx])
}
}
/// A 4×4 matrix, `m.0[row][col]`, acting on column vectors.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Matrix(pub [[f64; 4]; 4]);
impl Default for Matrix {
fn default() -> Self {
Matrix::IDENTITY
}
}
impl Matrix {
/// The identity matrix.
pub const IDENTITY: Matrix = Matrix([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]);
/// The entry at `row`, `col`; 0 outside the matrix.
#[must_use]
pub fn at(&self, row: usize, col: usize) -> f64 {
self.0.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0)
}
/// `self × b`: `b` applied first.
#[must_use]
pub fn multiply(&self, b: &Matrix) -> Matrix {
let mut m = [[0.0; 4]; 4];
for (i, row) in m.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = (0..4).map(|k| self.at(i, k) * b.at(k, j)).sum();
}
}
Matrix(m)
}
/// `self × (p, 1)`, divided by the homogeneous coordinate when it is neither 0 nor 1.
#[must_use]
pub fn transform_point(&self, p: [f64; 3]) -> [f64; 3] {
let [x, y, z] = p;
let row = |i: usize| self.at(i, 0) * x + self.at(i, 1) * y + self.at(i, 2) * z + self.at(i, 3);
let w = row(3);
let s = if w == 0.0 || w == 1.0 { 1.0 } else { 1.0 / w };
[row(0) * s, row(1) * s, row(2) * s]
}
/// The determinant of the upper-left 3×3 block.
fn det3(&self) -> f64 {
let a = |i, j| self.at(i, j);
a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0))
+ a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0))
}
/// The determinant of the whole matrix, by expansion along the last row.
fn det4(&self) -> f64 {
(0..4)
.map(|j| {
let cols: Vec<usize> = (0..4).filter(|&c| c != j).collect();
let a = |i: usize, k: usize| cols.get(k).map_or(0.0, |&c| self.at(i, c));
let minor = a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0))
+ a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0));
let sign = if (3 + j) % 2 == 0 { 1.0 } else { -1.0 };
sign * self.at(3, j) * minor
})
.sum()
}
/// Whether mapping points through this matrix turns them inside out. Where the point is
/// divided by `w`, the Jacobian's sign is the sign of the 4×4 determinant; a last row of
/// zeros (no division) falls back to the 3×3 block. Each row is first divided by its
/// largest entry, which keeps both signs while the products stay in range.
fn mirrors(&self) -> bool {
let scaled = Matrix(self.0.map(|row| {
let big = row.iter().fold(0.0_f64, |m, c| m.max(c.abs()));
if big.is_finite() && big > 0.0 { row.map(|c| c / big) } else { row }
}));
let det = scaled.det4();
if det == 0.0 { scaled.det3() < 0.0 } else { det < 0.0 }
}
/// Cofactor `(i, j)` of the upper-left 3×3 block (`i`, `j` < 3).
fn cofactor(&self, i: usize, j: usize) -> f64 {
let (r0, r1) = ((i + 1) % 3, (i + 2) % 3);
let (c0, c1) = ((j + 1) % 3, (j + 2) % 3);
self.at(r0, c0) * self.at(r1, c1) - self.at(r0, c1) * self.at(r1, c0)
}
}
impl TriangleMesh {
/// This mesh with every position mapped through `m` and every stored normal through its
/// inverse transpose (renormalised); a mirroring `m` (negative 4×4 determinant, which a
/// negative `w` can cause too) reverses each triangle so the outside stays
/// counter-clockwise. A projective `m` (a last row other than
/// `[0, 0, 0, w]`) bends the surface per point, so the stored normals are dropped.
///
/// ```
/// use pdfcraft_prc::{Matrix, TriangleMesh};
/// let mut mirror = Matrix::IDENTITY;
/// mirror.0[0][0] = -1.0;
/// let mut mesh = TriangleMesh::default();
/// mesh.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
/// mesh.triangles = vec![[0, 1, 2]];
/// let placed = mesh.transformed(&mirror);
/// assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
/// assert_eq!(placed.triangles, [[0, 2, 1]]);
/// ```
#[must_use]
pub fn transformed(&self, m: &Matrix) -> Self {
let mut out = self.clone();
for p in &mut out.positions {
*p = m.transform_point(*p);
}
// The cofactor matrix is det times the inverse transpose, so its sign is corrected
// and the result renormalised.
let mirrors = m.mirrors();
let sign = if mirrors { -1.0 } else { 1.0 };
if m.at(3, 0) != 0.0 || m.at(3, 1) != 0.0 || m.at(3, 2) != 0.0 {
out.normals.clear();
out.triangle_normals.clear();
}
for n in &mut out.normals {
let [x, y, z] = *n;
let v = [0, 1, 2].map(|i| sign * (m.cofactor(i, 0) * x + m.cofactor(i, 1) * y + m.cofactor(i, 2) * z));
let len = v.iter().map(|c| c * c).sum::<f64>().sqrt();
*n = if len > 0.0 && len.is_finite() { v.map(|c| c / len) } else { v };
}
if mirrors {
for t in &mut out.triangles {
t.swap(1, 2);
}
for t in &mut out.triangle_normals {
t.swap(1, 2);
}
for t in out.triangle_uvs.iter_mut().flatten().flatten() {
t.swap(1, 2);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_follows_the_right_hand_rule() {
assert_eq!(Vec3([1.0, 0.0, 0.0]).cross(Vec3([0.0, 1.0, 0.0])), Vec3([0.0, 0.0, 1.0]));
assert_eq!(Vec3([0.0, 1.0, 0.0]).cross(Vec3([1.0, 0.0, 0.0])), Vec3([0.0, 0.0, -1.0]));
}
#[test]
fn multiply_applies_the_right_operand_first() {
let mut t = Matrix::IDENTITY;
t.0[0][3] = 5.0;
let mut s = Matrix::IDENTITY;
s.0[0][0] = 2.0;
assert_eq!(t.multiply(&s).transform_point([1.0, 0.0, 0.0]), [7.0, 0.0, 0.0]);
assert_eq!(s.multiply(&t).transform_point([1.0, 0.0, 0.0]), [12.0, 0.0, 0.0]);
}
#[test]
fn a_projective_point_is_divided_by_w() {
let mut m = Matrix::IDENTITY;
m.0[3][3] = 2.0;
assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [1.0, 2.0, 3.0]);
m.0[3][3] = 0.0;
assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [2.0, 4.0, 6.0]);
}
#[test]
fn normals_take_the_inverse_transpose_and_mirror_with_their_triangle() {
// A shear in x by y turns the plane x = 0 into x = y; its x normal tilts to stay perpendicular.
let mut shear = Matrix::IDENTITY;
shear.0[0][1] = 1.0;
let mesh = TriangleMesh {
positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
normals: vec![[1.0, 0.0, 0.0]],
triangle_normals: vec![[0, 0, 0]],
..TriangleMesh::default()
};
let n = shear.transformed_normal(&mesh);
let s = std::f64::consts::FRAC_1_SQRT_2;
assert!((n[0] - s).abs() < 1e-12 && (n[1] + s).abs() < 1e-12 && n[2] == 0.0, "{n:?}");
let mut mirror = Matrix::IDENTITY;
mirror.0[2][2] = -1.0;
let placed = mesh.transformed(&mirror);
assert_eq!(placed.triangles, [[0, 2, 1]]);
assert_eq!(placed.triangle_normals, [[0, 0, 0]]);
assert_eq!(placed.normals, [[1.0, 0.0, 0.0]]);
}
#[test]
fn a_projective_transform_drops_the_stored_normals() {
let mesh = TriangleMesh {
positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
normals: vec![[1.0, 0.0, 1.0]],
triangle_normals: vec![[0, 0, 0]],
..TriangleMesh::default()
};
let mut projective = Matrix::IDENTITY;
projective.0[3][0] = 1.0;
let placed = mesh.transformed(&projective);
assert!(placed.normals.is_empty() && placed.triangle_normals.is_empty());
// A uniform w keeps them.
let mut uniform = Matrix::IDENTITY;
uniform.0[3][3] = 2.0;
assert_eq!(mesh.transformed(&uniform).triangle_normals, [[0, 0, 0]]);
}
/// A negative `w` turns the mapped triangles inside out though the 3×3 block does not
/// mirror, so the winding is reversed by the sign of the whole determinant.
#[test]
fn a_negative_w_reverses_the_winding() {
let mesh = TriangleMesh {
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
normals: vec![[1.0, 0.0, 0.0]],
triangle_normals: vec![[0, 0, 0]],
..TriangleMesh::default()
};
let mut negated = Matrix::IDENTITY;
negated.0[3][3] = -1.0;
let placed = mesh.transformed(&negated);
assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]]);
assert_eq!(placed.triangles, [[0, 2, 1]]);
assert_eq!(placed.normals, [[-1.0, 0.0, 0.0]]);
// A projective w that is negative over the mesh: w = z - 3.
let mut projective = Matrix::IDENTITY;
projective.0[3][2] = 1.0;
projective.0[3][3] = -3.0;
assert_eq!(mesh.transformed(&projective).triangles, [[0, 2, 1]]);
// A positive w, uniform or projective, keeps the winding.
let mut positive = Matrix::IDENTITY;
positive.0[3][2] = 1.0;
positive.0[3][3] = 3.0;
assert_eq!(mesh.transformed(&positive).triangles, [[0, 1, 2]]);
}
#[test]
fn a_mirror_whose_determinant_underflows_still_reverses_the_winding() {
let mesh = TriangleMesh {
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
..TriangleMesh::default()
};
let diagonal = |x: f64| {
let mut m = Matrix::IDENTITY;
(m.0[0][0], m.0[1][1], m.0[2][2]) = (x, 1e-200, 1e-200);
m
};
assert_eq!(mesh.transformed(&diagonal(-1e200)).triangles, [[0, 2, 1]]);
assert_eq!(mesh.transformed(&diagonal(1e200)).triangles, [[0, 1, 2]]);
}
impl Matrix {
fn transformed_normal(&self, mesh: &TriangleMesh) -> [f64; 3] {
mesh.transformed(self).normals.first().copied().unwrap_or_default()
}
}
}
+37
View File
@@ -0,0 +1,37 @@
//! A reader for PRC, the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries
//! (ISO 32000-2 §13.6.3, ISO 14739-1).
//!
//! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the
//! uncompressed container, inflates each section, and decodes the bit-level primitives and
//! the schema a newer producer uses to describe fields this reader does not know; it decodes
//! tessellations and walks the product tree into placed, coloured meshes
//! ([`PrcFile::placements`], [`PrcFile::model_tree`]).
//!
//! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570.
//!
//! Every read is bounded: inflation by [`MAX_INFLATED_BYTES`] (or a caller's limit), decoded
//! array elements by [`MAX_DECODED_ELEMENTS`] and schema-program steps by [`MAX_SCHEMA_STEPS`]
//! per reading pass, schema programs by a depth ceiling. Damaged data is an error, never a panic.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod acof;
mod arrays;
mod bits;
mod container;
mod entity;
mod error;
mod geometry;
mod schema;
mod tess;
#[cfg(test)]
mod testw;
mod tree;
pub use bits::{BitReader, MAX_DECODED_ELEMENTS, MAX_SCHEMA_STEPS};
pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId};
pub use error::PrcError;
pub use geometry::Matrix;
pub use schema::Schema;
pub use tess::{Tessellation, TriangleMesh, TriangleStyle};
pub use tree::{EntityOverrides, MAX_PLACEMENTS, ModelNode, NameSource, Placement, Rgba, StyleAlpha};
+565
View File
@@ -0,0 +1,565 @@
//! `FileStructureSchema` and its token interpreter [WD 6.3, 9].
//!
//! A producer newer than the reader describes, per entity type, the fields it appended; the
//! reader runs that program after the fields it knows, to step over the rest [WD 9.1]. Token
//! values follow the WD 9.2 table as corrected by PDF Association pdf-issues #407
//! (`Block_Start` = 19; 39 and 40 swallow one token).
//!
//! Choices the sources leave open, made here:
//! - `SimpleFor` reads its count as an `UnsignedInteger` [WD 9.3.17 pseudocode; pdf-issues
//! #575]: that read also decodes an `Integer`-written non-negative count, while a signed
//! read does not decode an unsigned one whose top group is 0x80 or higher.
//! - `Block_Version V` runs its block only when `V` is newer than
//! [`crate::PRC_READER_VERSION`]: an older block holds fields this reader already reads
//! natively [WD 9.3.20].
//! - `Extent_1D` and `Extent_2D` read 2 and 4 `Double`s: `Interval` and `Domain`
//! [WD 8.2.2, 8.2.4].
//! - Expression values are `i64`; a `Double` read as an operand truncates (NaN reads as 0).
//! - Variables are not block-scoped: a program that reuses an index after its block ends
//! reads the stale value instead of an error.
//! - Pointer tokens (12-14) and `Value_CurveIs3D` (28) need the exact-geometry reader and are
//! refused with [`PrcError::Unsupported`].
use std::collections::HashMap;
use crate::PrcError;
use crate::bits::BitReader;
/// Nesting depth of blocks, loops, expressions and `Father_Type` calls.
const MAX_DEPTH: u32 = 64;
/// The per-entity-type programs at the start of a globals or model-file
/// bitstream [WD 6.3.1].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Schema {
programs: HashMap<u32, Vec<u32>>,
}
impl Schema {
/// Read a schema: `UnsignedInteger` count, then per entry the entity
/// type, a token count and the tokens [WD 6.3.2].
///
/// # Errors
/// [`PrcError::Truncated`] when a count runs past the data;
/// [`PrcError::Malformed`] for an entity type listed twice; [`PrcError::TooManyElements`]
/// past the reader's allowance, one element per entry and per token.
pub fn read(r: &mut BitReader<'_>) -> Result<Self, PrcError> {
let n = r.unsigned_integer()? as usize;
// Every entry takes at least two bits.
if n > r.remaining() / 2 {
return Err(PrcError::Truncated("the schema"));
}
let mut programs = HashMap::new();
for _ in 0..n {
// The entry itself costs one element, so empty programs are not free.
r.take_elements(1)?;
let entity_type = r.unsigned_integer()?;
let len = r.unsigned_integer()? as usize;
if len > r.remaining() {
return Err(PrcError::Truncated("a schema program"));
}
r.take_elements(len)?;
let tokens = (0..len).map(|_| r.unsigned_integer()).collect::<Result<Vec<_>, _>>()?;
if programs.insert(entity_type, tokens).is_some() {
return Err(PrcError::Malformed(format!("schema lists entity type {entity_type} twice")));
}
}
Ok(Self { programs })
}
/// True when no entity type has added fields (the common case: the file
/// was written at the version this reader implements).
pub fn is_empty(&self) -> bool {
self.programs.is_empty()
}
/// The entity types the schema describes, unordered.
pub fn entity_types(&self) -> impl Iterator<Item = u32> + '_ {
self.programs.keys().copied()
}
/// Consume the fields a newer producer appended to `entity_type`, if the
/// schema describes it; a no-op otherwise. Each step is charged to `r`, so every
/// program run on one stream shares [`crate::MAX_SCHEMA_STEPS`].
///
/// # Errors
/// [`PrcError::Unsupported`] for a token that needs exact geometry;
/// [`PrcError::Malformed`] for an ill-formed program, one past the depth ceiling, or
/// one that runs out of the stream's steps; any read error from `r`.
pub fn skip_added_fields(&self, entity_type: u32, r: &mut BitReader<'_>) -> Result<(), PrcError> {
let Some(program) = self.programs.get(&entity_type) else {
return Ok(());
};
let mut exec = Exec { schema: self, r, vars: HashMap::new(), loop_counter: 0 };
exec.program(program, 0)
}
}
struct Exec<'s, 'r, 'd> {
schema: &'s Schema,
r: &'r mut BitReader<'d>,
vars: HashMap<u32, i64>,
loop_counter: i64,
}
fn malformed(what: &str) -> PrcError {
PrcError::Malformed(format!("schema program: {what}"))
}
/// The token at `*pos`, advancing past it.
fn next(prog: &[u32], pos: &mut usize) -> Result<u32, PrcError> {
let t = prog.get(*pos).copied().ok_or_else(|| malformed("ends mid-statement"))?;
*pos += 1;
Ok(t)
}
impl Exec<'_, '_, '_> {
fn tick(&mut self, depth: u32) -> Result<(), PrcError> {
if !self.r.take_step() {
return Err(malformed("step ceiling reached"));
}
if depth > MAX_DEPTH {
return Err(malformed("nesting ceiling reached"));
}
Ok(())
}
fn program(&mut self, prog: &[u32], depth: u32) -> Result<(), PrcError> {
let mut pos = 0;
while pos < prog.len() {
self.stmt(prog, &mut pos, true, depth)?;
}
Ok(())
}
/// One data read for tokens 0-5 and 7-11, returning its value as an
/// operand.
fn read(&mut self, token: u32) -> Result<i64, PrcError> {
let doubles = match token {
0 => return Ok(i64::from(self.r.bit()?)),
1 => return Ok(self.r.double()? as i64),
2 => return Ok(i64::from(self.r.character()?)),
3 => return Ok(i64::from(self.r.unsigned_integer()?)),
4 => return Ok(i64::from(self.r.integer()?)),
5 => {
self.r.string()?;
return Ok(0);
}
7 => 2,
8 => 3,
9 => 2,
10 => 4,
11 => 6,
_ => return Err(malformed("not a data token")),
};
for _ in 0..doubles {
self.r.double()?;
}
Ok(0)
}
/// Statements until `Block_End`, which is consumed.
fn block(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
loop {
if prog.get(*pos) == Some(&21) {
*pos += 1;
return Ok(());
}
self.stmt(prog, pos, run, depth + 1)?;
}
}
/// Run `body` (one statement at `*pos`) `count` times; `*pos` ends past it.
fn repeat(&mut self, prog: &[u32], pos: &mut usize, count: i64, run: bool, depth: u32) -> Result<(), PrcError> {
let start = *pos;
self.stmt(prog, pos, false, depth + 1)?;
if run {
let saved = self.loop_counter;
for i in 0..count.max(0) {
self.tick(depth)?;
self.loop_counter = i;
let mut p = start;
self.stmt(prog, &mut p, true, depth + 1)?;
}
self.loop_counter = saved;
}
Ok(())
}
/// One statement; with `run` false, only step over its tokens.
fn stmt(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
self.tick(depth)?;
match next(prog, pos)? {
t @ (0..=5 | 7..=11) => {
if run {
self.read(t)?;
}
}
6 => self.parent_program(prog, pos, run, depth)?,
12 => {
next(prog, pos)?;
if run {
return Err(PrcError::Unsupported("a schema pointer field"));
}
}
13 | 14 => {
if run {
return Err(PrcError::Unsupported("a schema pointer field"));
}
}
15 => {
let count = self.expr(prog, pos, run, depth + 1)?;
self.repeat(prog, pos, count, run, depth)?;
}
16 => {
// prc-rs: reads SimpleFor's count as an Integer; this reader deliberately reads an
// UnsignedInteger (module doc).
let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 };
self.repeat(prog, pos, count, run, depth)?;
}
17 => self.conditional(prog, pos, run, depth)?,
19 => self.block(prog, pos, run, depth)?,
20 => {
let version = next(prog, pos)?;
let newer = version > crate::PRC_READER_VERSION;
self.block(prog, pos, run && newer, depth)?;
}
22 => {
let v = next(prog, pos)?;
if run {
self.vars.insert(v, 0);
}
}
23 | 24 => {
let v = next(prog, pos)?;
let value = self.expr(prog, pos, run, depth + 1)?;
if run {
self.vars.insert(v, value);
}
}
39 | 40 => {
next(prog, pos)?;
}
t => return Err(malformed(&format!("token {t} where a statement belongs"))),
}
Ok(())
}
/// Token 6: run the named type's own program, the fields it inherits.
fn parent_program(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
let parent = next(prog, pos)?;
if run && let Some(p) = self.schema.programs.get(&parent) {
self.tick(depth + 1)?;
let mut q = 0;
while q < p.len() {
self.stmt(p, &mut q, true, depth + 1)?;
}
}
Ok(())
}
/// Token 17: `if` with an optional token-18 `else`.
fn conditional(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
let cond = self.expr(prog, pos, run, depth + 1)? != 0;
self.stmt(prog, pos, run && cond, depth + 1)?;
if prog.get(*pos) == Some(&18) {
*pos += 1;
self.stmt(prog, pos, run && !cond, depth + 1)?;
}
Ok(())
}
/// One expression; with `run` false its value is 0 and nothing is read.
fn expr(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<i64, PrcError> {
self.tick(depth)?;
let t = next(prog, pos)?;
match t {
0..=4 => {
if run {
self.read(t)
} else {
Ok(0)
}
}
25 => {
let v = next(prog, pos)?;
if !run {
return Ok(0);
}
self.vars.get(&v).copied().ok_or_else(|| malformed(&format!("variable {v} read before it is set")))
}
26 => Ok(i64::from(next(prog, pos)?)),
27 => Ok(self.loop_counter),
28 => {
if run {
Err(PrcError::Unsupported("Value_CurveIs3D"))
} else {
Ok(0)
}
}
29..=38 => {
let a = self.expr(prog, pos, run, depth + 1)?;
let b = self.expr(prog, pos, run, depth + 1)?;
if !run {
return Ok(0);
}
let overflow = || malformed("a schema expression overflows");
Ok(match t {
29 => a.checked_mul(b).ok_or_else(overflow)?,
30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?,
31 => a.checked_add(b).ok_or_else(overflow)?,
32 => a.checked_sub(b).ok_or_else(overflow)?,
33 => i64::from(a < b),
34 => i64::from(a <= b),
35 => i64::from(a > b),
36 => i64::from(a >= b),
37 => i64::from(a == b),
_ => i64::from(a != b),
})
}
t => Err(malformed(&format!("token {t} where a value belongs"))),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
use super::*;
use crate::testw::W;
fn schema(entries: &[(u32, &[u32])]) -> W {
let mut w = W::default();
w.uint(entries.len() as u32);
for (t, toks) in entries {
w.uint(*t).uint(toks.len() as u32);
for k in *toks {
w.uint(*k);
}
}
w
}
/// Each schema entry and its program's tokens are charged against the reader's element
/// allowance.
#[test]
fn program_tokens_are_charged_against_the_allowance() {
let bytes = schema(&[(172, &[1, 2, 3])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::with_elements(&bytes, 3)), Err(PrcError::TooManyElements { .. })));
assert!(Schema::read(&mut BitReader::with_elements(&bytes, 4)).is_ok());
let empty = schema(&[(172, &[]), (173, &[]), (174, &[])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::with_elements(&empty, 2)), Err(PrcError::TooManyElements { .. })));
assert!(Schema::read(&mut BitReader::with_elements(&empty, 3)).is_ok());
}
fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) {
let mut w = schema(entries);
w.append(data);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let s = Schema::read(&mut r).unwrap();
let start = r.position();
let res = s.skip_added_fields(172, &mut r);
(res, r.position() - start)
}
#[test]
fn integer_string_and_boolean_tokens_consume_their_fields() {
let mut d = W::default();
d.bit(true).uint(300).int(-7).string(Some("ab")).put(0x41, 8);
let want = d.len();
d.uint(99); // not part of the added fields
let (res, used) = run(&[(172, &[0, 3, 4, 5, 2])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn data_tokens_consume_their_fields() {
let mut d = W::default();
d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0);
let want = d.len();
d.uint(99); // not part of the added fields
let (res, used) = run(&[(172, &[0, 1, 3, 4, 5, 7])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn vector_extent_and_box_tokens_read_their_doubles() {
// Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles.
let mut d = W::default();
for i in 0..15 {
d.double(f64::from(i) + 0.5);
}
let want = d.len();
d.uint(99);
let (res, used) = run(&[(172, &[8, 9, 10, 11])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn arithmetic_and_comparison_operators() {
// For (5 - 3) { UInt }, then If (2 < 2) { Bool } Else { Char }.
let mut d = W::default();
d.uint(7).uint(8).put(0x41, 8);
let want = d.len();
d.uint(99);
let prog = [15, 32, 26, 5, 26, 3, 3, 17, 33, 26, 2, 26, 2, 0, 18, 2];
let (res, used) = run(&[(172, &prog)], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn an_absent_type_is_a_no_op() {
let mut w = schema(&[(300, &[3])]);
w.uint(5);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let s = Schema::read(&mut r).unwrap();
let at = r.position();
s.skip_added_fields(172, &mut r).unwrap();
assert_eq!(r.position(), at);
assert!(!s.is_empty());
}
#[test]
fn simple_for_reads_its_count_then_repeats_the_body() {
let mut d = W::default();
d.int(3).uint(1).uint(2).uint(3);
let want = d.len();
d.uint(77);
let (res, used) = run(&[(172, &[16, 3])], &d);
res.unwrap();
assert_eq!(used, want);
// A block body.
let mut d = W::default();
d.int(2).bit(true).uint(9).bit(false).uint(8);
let want = d.len();
let (res, used) = run(&[(172, &[16, 19, 0, 3, 21])], &d);
res.unwrap();
assert_eq!(used, want);
// A count whose top byte is 0x80 or higher, written either way.
for signed in [false, true] {
let mut d = W::default();
if signed {
d.int(200);
} else {
d.uint(200);
}
(0..200).for_each(|_| {
d.bit(true);
});
let want = d.len();
d.uint(77);
let (res, used) = run(&[(172, &[16, 0])], &d);
res.unwrap();
assert_eq!(used, want, "signed={signed}");
}
}
#[test]
fn if_else_and_variables_choose_the_branch_by_file_data() {
// DeclareAndSet v0 = <UInt from file>; if v0 > 1 { UInt } else { Character }.
let prog: &[u32] = &[24, 0, 3, 17, 35, 25, 0, 26, 1, 3, 18, 2];
let mut d = W::default();
d.uint(5).uint(4250);
let want = d.len();
let (res, used) = run(&[(172, prog)], &d);
res.unwrap();
assert_eq!(used, want);
let mut d = W::default();
d.uint(0).put(0xAB, 8);
let want = d.len();
let (res, used) = run(&[(172, prog)], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn for_counts_by_expression_and_exposes_the_counter() {
// For (2 * 2) { if counter == 2 { UInt } } => one UInt read.
let prog: &[u32] = &[15, 29, 26, 2, 26, 2, 17, 37, 27, 26, 2, 3];
let mut d = W::default();
d.uint(1234);
let want = d.len();
let (res, used) = run(&[(172, prog)], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn an_expression_that_overflows_is_malformed() {
// For ((2^31 * 2^31) * 4) { UInt }: 2^64 would wrap to 0 and read nothing.
let prog: &[u32] = &[15, 29, 29, 26, 1 << 31, 26, 1 << 31, 26, 4, 3];
let (res, _) = run(&[(172, prog)], &W::default());
assert!(matches!(res, Err(PrcError::Malformed(m)) if m.contains("overflows")));
}
#[test]
fn version_blocks_run_only_when_newer_than_the_reader() {
let mut d = W::default();
d.uint(42);
let want = d.len();
let (res, used) = run(&[(172, &[20, 8137, 3, 21, 20, 9000, 3, 21])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn father_type_runs_the_parent_program_and_obsolete_tokens_swallow_one() {
let mut d = W::default();
d.uint(1).uint(2);
let want = d.len();
let (res, used) = run(&[(172, &[39, 3, 6, 50, 40, 8]), (50, &[3, 3])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn hostile_programs_are_refused_not_run_forever() {
let d = W::default();
// For (1000000 * 1000000) { empty block }.
let prog: &[u32] = &[15, 29, 26, 1_000_000, 26, 1_000_000, 19, 21];
assert!(matches!(run(&[(172, prog)], &d).0, Err(PrcError::Malformed(_))));
// Father_Type recursing into itself.
assert!(matches!(run(&[(172, &[6, 172])], &d).0, Err(PrcError::Malformed(_))));
// A pointer field.
assert!(matches!(run(&[(172, &[13])], &d).0, Err(PrcError::Unsupported(_))));
// A stray Block_End, an unterminated block, a division by zero.
assert!(matches!(run(&[(172, &[21])], &d).0, Err(PrcError::Malformed(_))));
assert!(matches!(run(&[(172, &[19, 0])], &d).0, Err(PrcError::Malformed(_))));
assert!(matches!(run(&[(172, &[23, 0, 30, 26, 1, 26, 0])], &d).0, Err(PrcError::Malformed(_))));
}
/// Every program run on one reader draws on one step allowance.
#[test]
fn programs_on_one_stream_share_the_step_allowance() {
// For (2^19) { empty block }: about 2^20 steps, so the 2^22 allowance fits three runs.
let bytes = schema(&[(172, &[15, 26, 1 << 19, 19, 21])]).bytes();
let mut r = BitReader::new(&bytes);
let s = Schema::read(&mut r).unwrap();
let runs = (0..5).take_while(|_| s.skip_added_fields(172, &mut r).is_ok()).count();
assert_eq!(runs, 3);
assert!(matches!(s.skip_added_fields(172, &mut r), Err(PrcError::Malformed(m)) if m.contains("step ceiling")));
}
#[test]
fn a_duplicated_entity_type_is_malformed() {
let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_))));
}
#[test]
fn an_entry_count_past_the_data_is_refused_before_any_entry_is_read() {
let mut w = W::default();
w.uint(1_000).uint(172).uint(0);
let bytes = w.bytes();
assert_eq!(Schema::read(&mut BitReader::new(&bytes)).unwrap_err(), PrcError::Truncated("the schema"));
}
}
+440
View File
@@ -0,0 +1,440 @@
//! The tessellation section: `FileStructureTessellation` (305) and its entities, `TESS_3D`
//! (172) with its `TESS_Face`s (174), `TESS_3D_Wire` (175), `TESS_Markup` (176) and
//! `TESS_3D_Compressed` (173) [WD 7.3.7, 7.8].
//!
//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7] so the entities after
//! it decode; its triangles are not rebuilt.
//!
//! The schema runs after the fields of each concrete type read here (201, 172, 173, 174, 175,
//! 176, 305), not for the abstract levels (`PRCBase`, `TESS`).
mod triangulate;
use crate::arrays;
use crate::bits::{BitReader, Budget};
use crate::entity::{Ctx, malformed};
use crate::{FileStructure, PrcError, SectionKind};
use triangulate::{Corners, Face, triangle_elements};
const FILE_STRUCTURE_TESSELLATION: u32 = 305;
const TESS_3D: u32 = 172;
const TESS_3D_COMPRESSED: u32 = 173;
const TESS_FACE: u32 = 174;
const TESS_3D_WIRE: u32 = 175;
const TESS_MARKUP: u32 = 176;
/// prc-rs: `origin` exists in a compressed tessellation from this version (pdf-issues #705).
const ORIGIN_FROM: u32 = 7031;
/// prc-rs: `has_loops` exists from this file-structure authoring version.
const HAS_LOOPS_FROM: u32 = 7039;
/// prc-rs: `must_recalculate_normals` exists from this version.
const RECALCULATE_FROM: u32 = 7047;
/// A wire header's count bits; the top four are flags [WD 7.8.7.5].
const WIRE_COUNT_MASK: u32 = 0x0FFF_FFFF;
const WIRE_IS_CLOSING: u32 = 0x1000_0000;
const WIRE_IS_CONTINUOUS: u32 = 0x2000_0000;
/// Normal angles wider than this are not a `ShortArray` [WD 7.8.9.7].
const MAX_ANGLE_BITS: u32 = 16;
/// One entry of the tessellation array; a representation item's `index_tessellation` selects
/// one by position.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Tessellation {
/// A `TESS_3D` triangle mesh.
Mesh(TriangleMesh),
/// A `TESS_3D_Wire`: polylines.
Wire(Vec<Vec<[f64; 3]>>),
/// A `TESS_Markup` (PMI drawing), read past but not decoded.
Markup,
/// A `TESS_3D_Compressed` mesh, read past but not rebuilt.
#[non_exhaustive]
Compressed {
/// The entity's triangle count.
triangles: usize,
},
}
/// A `TESS_3D` decoded to indexed triangles, in the entity's own frame (the representation
/// item's placement is not applied).
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct TriangleMesh {
/// The coordinate array as points.
pub positions: Vec<[f64; 3]>,
/// Counter-clockwise triangles seen from the outside, as indices into
/// [`Self::positions`] [WD 7.8.5.2].
pub triangles: Vec<[u32; 3]>,
/// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`.
pub faces: Vec<std::ops::Range<usize>>,
/// The producer asks the reader to compute normals (none are stored).
pub normals_recalculated: bool,
/// The stored vertex normals [WD 7.8.5.1], as written (not renormalised); empty when the
/// mesh stores none or any of its normal indices is unusable.
pub normals: Vec<[f64; 3]>,
/// Per triangle, the index into [`Self::normals`] of each corner's normal, in the corner
/// order of [`Self::triangles`]; empty exactly when [`Self::normals`] is.
pub triangle_normals: Vec<[u32; 3]>,
/// Per triangle, the style its face's line attributes give it [WD 7.8.6]; empty when no
/// face carries any.
pub triangle_styles: Vec<TriangleStyle>,
/// The stored texture coordinates as (u, v) pairs [WD 7.8.5.1]; empty when the mesh
/// stores none.
pub uvs: Vec<[f64; 2]>,
/// Per texture-coordinate set, per triangle, the index into [`Self::uvs`] of each
/// corner's coordinate, in the corner order of [`Self::triangles`]; each set is as long
/// as [`Self::triangles`], `None` for a triangle whose face stores no coordinate for the
/// set. Empty when [`Self::uvs`] is.
pub triangle_uvs: Vec<Vec<Option<[u32; 3]>>>,
}
/// The graphics a face's `line_attributes` give one triangle [WD 7.8.6].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct TriangleStyle {
/// An index into the globals' style array plus one; 0 keeps the owner's graphics.
pub style: u32,
/// The face's behaviour bits [WD 5.4].
pub behaviour: u16,
}
impl FileStructure {
/// The tessellation section's entities, in the order a representation item's
/// `index_tessellation` counts them [WD 7.3.7].
///
/// # Errors
/// As [`crate::Schema::read`] for the globals' schema; [`PrcError::Truncated`] or
/// [`PrcError::Malformed`] for a damaged section; [`PrcError::Unsupported`] for
/// optimised vertex colours.
pub fn tessellations(&self) -> Result<Vec<Tessellation>, PrcError> {
self.tessellations_within(Budget::FULL)
}
/// As [`Self::tessellations`], the globals' schema and the section sharing `budget`.
pub(crate) fn tessellations_within(&self, budget: Budget) -> Result<Vec<Tessellation>, PrcError> {
let mut globals = BitReader::with_budget(self.section(SectionKind::Globals), budget);
let schema = crate::Schema::read(&mut globals)?;
let r = BitReader::with_budget(self.section(SectionKind::Tessellation), globals.budget());
Ctx::new(r, &schema, self.authoring_version).file_structure_tessellation()
}
}
impl Ctx<'_, '_> {
/// `FileStructureTessellation` (305) [WD 7.3.7].
pub(crate) fn file_structure_tessellation(&mut self) -> Result<Vec<Tessellation>, PrcError> {
self.expect_type(FILE_STRUCTURE_TESSELLATION)?;
self.content_prc_base()?;
let n = self.count(1, "tessellations")?;
let mut out = Vec::with_capacity(n.min(1024));
for _ in 0..n {
out.push(match self.r.unsigned_integer()? {
TESS_3D => Tessellation::Mesh(self.tess_3d()?),
TESS_3D_WIRE => Tessellation::Wire(self.wire()?),
TESS_MARKUP => {
self.markup()?;
Tessellation::Markup
}
TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? },
t => return Err(malformed(format!("entity type {t} in the tessellation array"))),
});
}
self.schema.skip_added_fields(FILE_STRUCTURE_TESSELLATION, &mut self.r)?;
self.user_data()?;
Ok(out)
}
/// `ContentBaseTessData` [WD 7.8.4]: `is_calculated`, then coordinates.
fn base_tess_data(&mut self) -> Result<Vec<f64>, PrcError> {
self.r.bit()?;
self.doubles("tessellation coordinates")
}
/// `VertexColors` [WD 7.8.7.2] for `count` points, read past.
fn vertex_colors(&mut self, count: usize, in_wire: bool) -> Result<(), PrcError> {
let rgba = self.r.bit()?;
// prc-rs: `is_segment_color` exists only inside a wire.
let per_segment = in_wire && self.r.bit()?;
if self.r.bit()? {
return Err(PrcError::Unsupported("optimised vertex colours"));
}
let count = if per_segment { count / 2 } else { count };
let bytes = if rgba { 4 } else { 3 };
// The first colour is `8 * bytes` bits; every later one is at least its reuse bit.
if count > 0 && (count - 1).saturating_add(8 * bytes) > self.r.remaining() {
return Err(PrcError::Truncated("vertex colours"));
}
for i in 0..count {
if i == 0 || !self.r.bit()? {
for _ in 0..bytes {
self.r.character()?;
}
}
}
Ok(())
}
/// `TESS_3D` (172) after its type code [WD 7.8.5].
fn tess_3d(&mut self) -> Result<TriangleMesh, PrcError> {
let coords = self.base_tess_data()?;
let _has_faces = self.r.bit()?;
if self.version >= HAS_LOOPS_FROM {
let _has_loops = self.r.bit()?;
}
let recalc = self.version >= RECALCULATE_FROM && self.r.bit()?;
if recalc {
let _flags = self.r.character()?;
let _crease_angle = self.r.double()?;
}
let normal_coords = self.doubles("normal coordinates")?;
let _wire_indices = self.uints("wire indices")?;
let indices = self.uints("triangulated indices")?;
let n_faces = self.count(1, "faces")?;
let mut faces = Vec::with_capacity(n_faces.min(1024));
for _ in 0..n_faces {
self.expect_type(TESS_FACE)?;
faces.push(self.face()?);
}
let texture = self.doubles("texture coordinates")?;
self.schema.skip_added_fields(TESS_3D, &mut self.r)?;
let mut mesh = TriangleMesh { positions: points(&coords)?, normals_recalculated: recalc, ..TriangleMesh::default() };
let styled = faces.iter().any(|f| !f.styles.is_empty());
let mut corners = Corners::default();
// Every triangle carries a corner triple for each texture set any face uses.
let sets = faces.iter().map(Face::texture_sets).max().unwrap_or(0);
let per_triangle = triangle_elements(!recalc, sets, styled);
for f in &faces {
// Faces may reuse one index range, so each face's triangles, with what they
// carry, are charged before they are made.
self.r.take_elements(f.triangle_count()?.saturating_mul(per_triangle))?;
let start = mesh.triangles.len();
let entities = f.triangulate(&indices, !recalc, mesh.positions.len(), &mut mesh.triangles, &mut corners)?;
mesh.faces.push(start..mesh.triangles.len());
if styled {
f.styles_into(&entities, &mut mesh.triangle_styles);
}
}
(mesh.normals, mesh.triangle_normals) = stored_normals(&normal_coords, &corners.normals, mesh.triangles.len());
(mesh.uvs, mesh.triangle_uvs) = stored_uvs(&texture, corners.textures);
Ok(mesh)
}
/// `TESS_Face` (174) after its type code [WD 7.8.6].
fn face(&mut self) -> Result<Face, PrcError> {
let styles = self.uints("face line attributes")?;
let _start_of_wire = self.r.unsigned_integer()?;
let _sizes_wire = self.uints("face wire sizes")?;
let flags = self.r.unsigned_integer()?;
let start = self.r.unsigned_integer()? as usize;
let data = self.uints("triangulated data")?;
let textures = self.r.unsigned_integer()? as usize;
let mut face = Face { flags, start, data, textures, styles, behaviour: 0 };
if self.r.bit()? {
let n = face.point_count()?;
self.vertex_colors(n, false)?;
}
if !face.styles.is_empty() {
let bits = self.r.unsigned_integer()?;
face.behaviour = u16::try_from(bits).map_err(|_| malformed(format!("face behaviour {bits} is wider than 16 bits")))?;
}
self.schema.skip_added_fields(TESS_FACE, &mut self.r)?;
Ok(face)
}
/// `TESS_3D_Wire` (175) after its type code [WD 7.8.7].
fn wire(&mut self) -> Result<Vec<Vec<[f64; 3]>>, PrcError> {
let pts = points(&self.base_tess_data()?)?;
let words = self.uints("wire indices")?;
let mut wires: Vec<Vec<[f64; 3]>> = Vec::new();
let mut colours = 0usize;
if words.is_empty() {
colours = pts.len();
if !pts.is_empty() {
wires.push(pts.clone());
}
}
let mut it = words.iter().copied();
while let Some(header) = it.next() {
let n = (header & WIRE_COUNT_MASK) as usize;
let mut wire = Vec::with_capacity(n.min(words.len()));
for _ in 0..n {
let idx = it.next().ok_or_else(|| malformed("wire runs past its indices".into()))?;
wire.push(point_at(&pts, idx)?);
}
colours = colours.saturating_add(n);
if header & WIRE_IS_CLOSING != 0 {
colours = colours.saturating_add(1);
if let Some(&first) = wire.first() {
wire.push(first);
}
}
match wires.last_mut() {
Some(prev) if header & WIRE_IS_CONTINUOUS != 0 => prev.extend(wire),
_ => wires.push(wire),
}
}
if self.r.bit()? {
self.vertex_colors(colours, true)?;
}
self.schema.skip_added_fields(TESS_3D_WIRE, &mut self.r)?;
Ok(wires)
}
/// `TESS_Markup` (176) after its type code [WD 7.8.8], read past.
fn markup(&mut self) -> Result<(), PrcError> {
self.base_tess_data()?;
self.uints("markup codes")?;
let n = self.count(1, "markup strings")?;
for _ in 0..n {
self.r.string()?;
}
self.r.string()?;
self.r.character()?;
self.schema.skip_added_fields(TESS_MARKUP, &mut self.r)
}
/// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7], read past; returns its
/// triangle count. prc-rs: the field order and version gates follow its schema.
///
/// Sizes the WD leaves unstated: the face count is the largest face index + 1,
/// `normal_is_reversed` is one bit per triangle, and the point-reference array's
/// compressed flag is implicit (more than three references).
fn tess_3d_compressed(&mut self) -> Result<usize, PrcError> {
let r = &mut self.r;
r.bit()?; // is_calculated
r.bit()?; // has_faces
r.double()?; // tolerance
if self.version >= ORIGIN_FROM {
for _ in 0..3 {
r.float_as_bytes()?;
}
}
arrays::compressed_integer_array(r)?; // point_array
let edge_status = arrays::character_array(r, 2, None, false)?;
let face_of = arrays::compressed_indice_array(r, None)?;
let t = face_of.len();
if edge_status.len() != t && edge_status.len() != t.saturating_mul(3) {
return Err(malformed(format!("{} edge statuses for {t} triangles", edge_status.len())));
}
let faces = face_of.iter().max().map_or(0, |&m| (m as usize).saturating_add(1));
let n = r.unsigned_integer()? as usize;
let references = arrays::bool_array(r, n)?.iter().filter(|&&b| b).count();
let refs = arrays::compressed_indice_array(r, Some(references > 3))?;
if refs.len() != references {
return Err(malformed(format!("{} point references for {references} flagged points", refs.len())));
}
self.compressed_normals(t, faces)?;
let r = &mut self.r;
if r.bit()? {
arrays::bool_array(r, faces)?; // is_point_color_on_face
arrays::character_array(r, 8, None, false)?; // point_color_array
}
if r.bit()? {
arrays::bool_array(r, faces)?; // is_multiple_line_attribute_on_face
}
arrays::short_array(r, 16)?; // line_attributes
if !r.bit()? {
self.compressed_texture_parameter()?;
if !self.r.bit()? {
arrays::bool_array(&mut self.r, faces)?; // face_has_texture
}
}
if self.r.bit()? {
arrays::character_array(&mut self.r, 8, None, false)?; // behaviours
}
self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?;
Ok(t)
}
/// A compressed tessellation's normals, from `must_recalculate_normals` on, read past.
fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> {
let r = &mut self.r;
if r.bit()? {
arrays::bool_array(r, triangles)?; // normal_is_reversed
r.double()?; // crease_angle
r.character()?; // normal_recalculation_flags
return Ok(());
}
let angle_bits = u32::from(r.character()?);
if angle_bits > MAX_ANGLE_BITS {
return Err(malformed(format!("{angle_bits}-bit normal angles")));
}
let n = r.unsigned_integer()? as usize;
arrays::bool_array(r, n)?; // is_binary_normal
arrays::short_array(r, angle_bits)?; // normal angles
arrays::bool_array(r, faces)?; // is_face_planar
Ok(())
}
/// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past.
fn compressed_texture_parameter(&mut self) -> Result<(), PrcError> {
let words = self.count(32, "texture data")?;
// count() bounded words * 32 by the remaining bits.
self.r.skip_bits(words.saturating_mul(32))?;
self.r.unsigned_integer()?; // last_integer_used_bit_number
let n = self.count(5, "texture references")?;
for _ in 0..n {
self.r.nbits_then_unsigned()?;
}
self.r.double()?; // tolerance
let n = self.count(32, "texture parameters")?;
for _ in 0..n {
self.r.float_as_bytes()?;
}
Ok(())
}
}
/// The normal array and each triangle's corner normals, or both empty when any slot fails to
/// name a normal: a mesh shades from its stored normals wholly or not at all.
fn stored_normals(coords: &[f64], slots: &[[u32; 3]], triangles: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
// Like a slot naming no normal, a partial last normal drops the stored normals.
let Ok(normals) = points(coords) else {
return (Vec::new(), Vec::new());
};
let named = |k: u32| k.is_multiple_of(3) && (k as usize / 3) < normals.len();
if normals.is_empty() || slots.len() != triangles || !slots.iter().flatten().all(|&k| named(k)) {
return (Vec::new(), Vec::new());
}
let corners = slots.iter().map(|t| t.map(|k| k / 3)).collect();
(normals, corners)
}
/// Per texture-coordinate set, each triangle's corner pair indices.
type UvSets = Vec<Vec<Option<[u32; 3]>>>;
/// The texture coordinates as (u, v) pairs, and per set the corner slots turned into pair
/// indices (a slot is a `Double` offset, so even); a triangle with a slot naming no pair is
/// `None`.
fn stored_uvs(coords: &[f64], sets: UvSets) -> (Vec<[f64; 2]>, UvSets) {
let uvs: Vec<[f64; 2]> = coords.as_chunks::<2>().0.to_vec();
if uvs.is_empty() {
return (Vec::new(), Vec::new());
}
let pair = |k: u32| (k.is_multiple_of(2) && (k as usize / 2) < uvs.len()).then_some(k / 2);
let sets =
sets.into_iter().map(|set| set.into_iter().map(|tri| tri.and_then(|[a, b, c]| Some([pair(a)?, pair(b)?, pair(c)?]))).collect()).collect();
(uvs, sets)
}
/// `coords` as points; an array that does not divide into whole points is malformed.
fn points(coords: &[f64]) -> Result<Vec<[f64; 3]>, PrcError> {
let (whole, rest) = coords.as_chunks::<3>();
if !rest.is_empty() {
return Err(malformed(format!("{} coordinates are not whole points", coords.len())));
}
Ok(whole.to_vec())
}
/// The point a `Double`-offset index names [WD 7.8.5.2: multiples of 3].
fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> {
if !idx.is_multiple_of(3) {
return Err(malformed(format!("point index {idx} is not a multiple of 3")));
}
pts.get(idx as usize / 3).copied().ok_or_else(|| malformed(format!("point index {idx} past the coordinates")))
}
#[cfg(test)]
pub(crate) mod tests;
+738
View File
@@ -0,0 +1,738 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::triangulate::MAX_TEXTURE_SETS;
use super::*;
use crate::Schema;
use crate::testw::W;
const SQUARE: [f64; 12] = [0., 0., 0., 1., 0., 0., 1., 1., 0., 0., 1., 0.];
const ATTRIBUTE: u32 = 201;
const NORMAL_SINGLE: u32 = 0x4000_0000;
/// A 305 section holding `body` (already-written entities, `n` of them).
fn section(n: u32, body: &W) -> W {
let mut w = W::default();
w.uint(305).uint(0).bit(true).uint(n).append(body).uint(0);
w
}
fn uints(w: &mut W, v: &[u32]) {
w.uint(u32::try_from(v.len()).unwrap());
for &x in v {
w.uint(x);
}
}
fn square(w: &mut W, entity: u32) {
coordinates(w, entity, &SQUARE);
}
fn coordinates(w: &mut W, entity: u32, coords: &[f64]) {
w.uint(entity).bit(false).uint(u32::try_from(coords.len()).unwrap());
for &c in coords {
w.double(c);
}
}
#[derive(Default)]
struct Mesh<'a> {
recalc: bool,
indices: &'a [u32],
faces: &'a [(u32, u32, &'a [u32])],
textures: u32,
uvs: &'a [f64],
/// The coordinates; [`SQUARE`] when `None`.
coords: Option<&'a [f64]>,
}
/// A TESS_3D over [`SQUARE`] at authoring version `v`.
fn tess_3d(w: &mut W, v: u32, m: &Mesh<'_>) {
coordinates(w, 172, m.coords.unwrap_or(&SQUARE));
w.bit(true);
if v >= 7039 {
w.bit(false);
}
if v >= 7047 {
w.bit(m.recalc);
if m.recalc {
w.put(0, 8).double(0.5);
}
}
if m.recalc {
w.uint(0);
} else {
// Two normals: slot 0 is +z, slot 3 is -z.
w.uint(6);
for c in [0., 0., 1., 0., 0., -1.] {
w.double(c);
}
}
w.uint(0);
uints(w, m.indices);
w.uint(u32::try_from(m.faces.len()).unwrap());
for &(flags, start, data) in m.faces {
w.uint(174).uint(0).uint(0).uint(0).uint(flags).uint(start);
uints(w, data);
w.uint(m.textures).bit(false);
}
w.uint(u32::try_from(m.uvs.len()).unwrap());
for &c in m.uvs {
w.double(c);
}
}
fn decode(w: &W, schema: &Schema, version: u32) -> Result<Vec<Tessellation>, PrcError> {
let bytes = w.bytes();
Ctx::new(BitReader::new(&bytes), schema, version).file_structure_tessellation()
}
fn mesh(t: &Tessellation) -> &TriangleMesh {
match t {
Tessellation::Mesh(m) => m,
other => panic!("not a mesh: {other:?}"),
}
}
fn one(indices: &[u32], faces: &[(u32, u32, &[u32])], textures: u32) -> TriangleMesh {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices, faces, textures, ..Mesh::default() });
mesh(&decode(&section(1, &b), &Schema::default(), 8137).unwrap()[0]).clone()
}
#[test]
fn triangle_and_fan_with_normals() {
// One triangle (n,p x3), then a fan of 4 vertices (n,p each).
let m = one(&[3, 0, 0, 3, 3, 6, 0, 0, 3, 3, 0, 6, 3, 9], &[(0x2 | 0x4, 0, &[1, 1, 4])], 0);
assert_eq!(m.positions.len(), 4);
assert_eq!(m.positions[2], [1., 1., 0.]);
assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [0, 2, 3]]);
assert_eq!(m.faces, vec![0..3]);
assert!(!m.normals_recalculated);
assert_eq!(m.normals, [[0., 0., 1.], [0., 0., -1.]]);
// Each corner keeps the normal stored beside its point.
assert_eq!(m.triangle_normals, [[1, 0, 1], [0, 1, 0], [0, 0, 1]]);
}
#[test]
fn strip_normals_follow_the_alternating_winding() {
let m = one(&[3, 0, 0, 3, 3, 6, 0, 9], &[(0x8, 0, &[1, 4])], 0);
assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]);
assert_eq!(m.triangle_normals, [[1, 0, 1], [1, 0, 0]]);
}
#[test]
fn a_normal_index_past_the_array_drops_every_normal_but_no_triangle() {
let m = one(&[0, 0, 6, 3, 0, 6], &[(0x2, 0, &[1])], 0);
assert_eq!(m.triangles, [[0, 1, 2]]);
assert!(m.normals.is_empty());
assert!(m.triangle_normals.is_empty());
}
#[test]
fn recalculated_normals_drop_the_normal_slots_and_strips_alternate() {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { recalc: true, indices: &[0, 3, 6, 9], faces: &[(0x8, 0, &[1, 4])], ..Mesh::default() });
let t = decode(&section(1, &b), &Schema::default(), 8137).unwrap();
let m = mesh(&t[0]);
assert!(m.normals_recalculated);
assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]);
assert!(m.normals.is_empty() && m.triangle_normals.is_empty());
}
#[test]
fn version_gates_the_loop_and_recalculation_flags() {
for v in [7038, 7039, 7046, 7047, 8137] {
let mut b = W::default();
tess_3d(&mut b, v, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() });
// A trailing wire: a misread flag desyncs it.
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), v).unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]], "version {v}");
assert!(matches!(t[1], Tessellation::Wire(_)), "version {v}");
}
}
#[test]
fn one_normal_blocks_and_textures() {
// 0x20: n,p,p,p. 0x40 with NORMAL_Single: n then p per vertex.
// 0x2000 with one texture set: n,t,p,t,p,t,p.
let idx = [
0, 0, 3, 6, // 0x20
3, 0, 6, 9, // 0x40 single, 3 vertices
0, 0, 9, 2, 0, 4, 3, // 0x2000
];
let m = one(&idx, &[(0x20 | 0x40 | 0x2000, 0, &[1, 1, 3 | NORMAL_SINGLE, 1])], 1);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3], [3, 0, 1]]);
// A one-normal entity gives every corner its single normal.
assert_eq!(m.triangle_normals, [[0, 0, 0], [1, 1, 1], [0, 0, 0]]);
}
#[test]
fn one_normal_fan_without_single_keeps_a_normal_per_vertex() {
let m = one(&[0, 0, 0, 3, 0, 6, 0, 9], &[(0x40, 0, &[1, 4])], 0);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]);
}
#[test]
fn textured_fan_and_strip_carry_texture_slots_per_vertex() {
// 0x400 then 0x800, two texture sets: n,t,t,p per vertex.
let mut idx = Vec::new();
for p in [0, 3, 6, 0, 3, 6, 9] {
idx.extend([0, 0, 2, p]);
}
let m = one(&idx, &[(0x400 | 0x800, 0, &[1, 3, 1, 4])], 2);
assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [2, 1, 3]]);
}
/// The unit square as two textured triangles (one set; per vertex a normal, a texture slot,
/// a point), its corners at the picture's corners.
fn textured_square() -> W {
let mut body = W::default();
let indices = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9];
tess_3d(
&mut body,
8137,
&Mesh { indices: &indices, faces: &[(0x200, 0, &[2])], textures: 1, uvs: &[0., 0., 1., 0., 1., 1., 0., 1.], ..Mesh::default() },
);
section(1, &body)
}
#[test]
fn a_textured_mesh_keeps_each_corners_coordinates() {
let t = decode(&textured_square(), &Schema::default(), 8137).unwrap();
let m = mesh(&t[0]);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]);
assert_eq!(m.uvs, [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
assert_eq!(m.triangle_uvs, [vec![Some([0, 1, 2]), Some([0, 2, 3])]]);
// An odd slot names no pair: that triangle has no coordinates.
let mut b = W::default();
tess_3d(
&mut b,
8137,
&Mesh { indices: &[0, 1, 0, 0, 2, 3, 0, 4, 6], faces: &[(0x200, 0, &[1])], textures: 1, uvs: &[0.0; 6], ..Mesh::default() },
);
let t = decode(&section(1, &b), &Schema::default(), 8137).unwrap();
let m = mesh(&t[0]);
assert_eq!(m.triangles, [[0, 1, 2]]);
assert_eq!(m.triangle_uvs, [vec![None]]);
}
#[test]
fn too_many_texture_sets_are_refused_before_anything_is_allocated() {
// An empty fan on a face claiming 2^32 - 1 texture sets: no slot bounds the count.
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: u32::MAX, ..Mesh::default() });
assert!(
matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == format!("{} texture-coordinate sets on a face", u32::MAX))
);
let sets = u32::try_from(MAX_TEXTURE_SETS).unwrap();
let mut ok = W::default();
tess_3d(&mut ok, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: sets, ..Mesh::default() });
assert!(decode(&section(1, &ok), &Schema::default(), 8137).is_ok());
}
/// A tessellation section holding one TESS_3D: [`SQUARE`] as two faces of one triangle each.
pub(crate) fn two_face_square() -> W {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], faces: &[(0x2, 0, &[1]), (0x2, 6, &[1])], ..Mesh::default() });
section(1, &b)
}
#[test]
fn two_faces_partition_the_triangles() {
let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]);
assert_eq!(m.faces, [0..1, 1..2]);
}
/// A wire over [`SQUARE`]: an open two-point wire continued by a closing two-point wire,
/// then optionally per-segment RGB colours.
fn wire(w: &mut W, colours: bool) {
square(w, 175);
uints(w, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]);
w.bit(colours);
if colours {
// 5 points (4 + the implicit closing one) -> 2 segment colours.
w.bit(false).bit(true).bit(false).put(0xff0000, 24);
w.bit(true);
}
}
#[test]
fn wires_close_and_continue_and_colours_are_counted() {
for colours in [false, true] {
let mut b = W::default();
wire(&mut b, colours);
// A second entity: a wrong colour count would desync it.
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
for e in &t {
let Tessellation::Wire(w) = e else { panic!("not a wire") };
assert_eq!(w, &[vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [1., 1., 0.]]]);
}
}
}
#[test]
fn a_closing_wire_colours_its_implicit_point() {
// Per-point RGB: 4 indexed points + 1 implicit = 5 colours.
let mut b = W::default();
square(&mut b, 175);
uints(&mut b, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]);
b.bit(true).bit(false).bit(false).bit(false).put(1, 24);
for _ in 1..5 {
b.bit(true);
}
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
assert!(matches!(t[1], Tessellation::Wire(_)));
assert_eq!(t[0], t[1]);
}
#[test]
fn a_wire_index_must_name_a_point() {
let mut b = W::default();
square(&mut b, 175);
uints(&mut b, &[2, 0, 12]);
b.bit(false);
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
}
#[test]
fn a_wire_without_indices_is_the_coordinate_polyline() {
let mut b = W::default();
square(&mut b, 175);
b.uint(0).bit(true);
// 4 implicit points -> 4 RGB colours, all "same" after the first.
b.bit(false).bit(false).bit(false).put(1, 24);
for _ in 1..4 {
b.bit(true);
}
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") };
assert_eq!(w.len(), 1);
assert_eq!(w[0].len(), 4);
assert!(matches!(t[1], Tessellation::Wire(_)));
}
/// Reused colours cost one bit each, so a long run of them may end the section.
#[test]
fn reused_vertex_colours_may_be_the_last_data() {
let n = 40;
let mut b = W::default();
b.uint(175).bit(false).uint(3 * n);
for i in 0..3 * n {
b.double(f64::from(i));
}
// No indices: the polyline of all `n` points, one RGB colour then `n - 1` reuses.
b.uint(0).bit(true).bit(false).bit(false).bit(false).put(1, 24);
for _ in 1..n {
b.bit(true);
}
let t = decode(&section(1, &b), &Schema::default(), 8137).unwrap();
let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") };
assert_eq!(w[0].len(), n as usize);
}
/// A square as one triangle plus one 4-vertex fan (two entities, three triangles), the face
/// carrying `attrs` and, when it has any, behaviour 0x30; then `colours` per-point colours
/// and a trailing wire.
fn styled_face(attrs: &[u32], colours: usize) -> Vec<Tessellation> {
decode(&styled_face_section(attrs, colours, 0x30), &Schema::default(), 8137).unwrap()
}
/// [`styled_face`]'s section, the face's behaviour `behaviour`.
fn styled_face_section(attrs: &[u32], colours: usize, behaviour: u32) -> W {
let mut b = W::default();
square(&mut b, 172);
b.bit(true).bit(false).bit(false).uint(0).uint(0);
uints(&mut b, &[0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]);
b.uint(1).uint(174);
uints(&mut b, attrs);
b.uint(0).uint(0).uint(0x2 | 0x4).uint(0);
uints(&mut b, &[1, 1, 4]);
b.uint(0).bit(colours > 0);
if colours > 0 {
b.bit(false).bit(false).put(0x10_2030, 24);
for _ in 1..colours {
b.bit(true);
}
}
if !attrs.is_empty() {
b.uint(behaviour);
}
b.uint(0);
wire(&mut b, false);
section(2, &b)
}
#[test]
fn face_vertex_colours_count_points_not_data() {
// A triangle plus a fan of 4 vertices = 7 points -> 7 colours.
let t = styled_face(&[], 7);
assert_eq!(mesh(&t[0]).triangles.len(), 3);
assert!(matches!(t[1], Tessellation::Wire(_)));
}
fn styles(attrs: &[u32]) -> Vec<(u32, u16)> {
let t = styled_face(attrs, 0);
assert!(matches!(t[1], Tessellation::Wire(_)));
mesh(&t[0]).triangle_styles.iter().map(|g| (g.style, g.behaviour)).collect()
}
#[test]
fn face_line_attributes_style_its_triangles() {
assert!(styles(&[]).is_empty());
assert_eq!(styles(&[5]), [(5, 0x30); 3]);
// One per entity: the triangle, then both triangles of the fan.
assert_eq!(styles(&[4, 7]), [(4, 0x30), (7, 0x30), (7, 0x30)]);
// An entity styled 0 inherits, as do entities past the list.
assert_eq!(styles(&[4, 0, 9]), [(4, 0x30), (0, 0x30), (0, 0x30)]);
}
#[test]
fn markup_is_read_past() {
let mut b = W::default();
b.uint(176).bit(false).uint(3);
for c in [1., 2., 3.] {
b.double(c);
}
uints(&mut b, &[6, 0]);
b.uint(1).string(Some("R1")).string(Some("label")).put(7, 8);
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
assert_eq!(t[0], Tessellation::Markup);
assert!(matches!(t[1], Tessellation::Wire(_)));
}
#[test]
fn schema_fields_are_skipped_after_each_entity() {
// A newer producer added one UnsignedInteger to TESS_3D and one to TESS_Face.
let mut s = W::default();
s.uint(2).uint(172).uint(1).uint(3).uint(174).uint(1).uint(3);
let sb = s.bytes();
let schema = Schema::read(&mut BitReader::new(&sb)).unwrap();
let mut b = W::default();
square(&mut b, 172);
b.bit(true).bit(false).bit(false).uint(0).uint(0);
uints(&mut b, &[0, 0, 0, 3, 0, 6]);
b.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0);
uints(&mut b, &[1]);
b.uint(0).bit(false).uint(99); // the face's added field
b.uint(0).uint(77); // texture count, then TESS_3D's added field
wire(&mut b, false);
let t = decode(&section(2, &b), &schema, 8137).unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]]);
assert!(matches!(t[1], Tessellation::Wire(_)));
// Without the schema the same bytes do not decode to the same thing.
assert_ne!(decode(&section(2, &b), &Schema::default(), 8137).ok(), Some(t));
}
#[test]
fn attributes_names_and_user_data_are_read_past() {
let mut w = W::default();
w.uint(305).uint(1).uint(ATTRIBUTE);
w.bit(true).uint(2); // predefined title
w.uint(2);
w.bit(false).string(Some("k")).uint(4).string(Some("v"));
w.bit(true).uint(1).uint(5).int(-1).uint(9);
w.uint(42); // the attribute's schema-added field
w.bit(false).string(Some("tess")).uint(1);
wire(&mut w, false);
w.uint(5).put(0b10110, 5);
let mut sw = W::default();
sw.uint(1).uint(ATTRIBUTE).uint(1).uint(3);
let sb = sw.bytes();
let schema = Schema::read(&mut BitReader::new(&sb)).unwrap();
let bytes = w.bytes();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137);
let t = ctx.file_structure_tessellation().unwrap();
assert!(matches!(t[..], [Tessellation::Wire(_)]));
assert_eq!(ctx.name.as_deref(), Some("tess"));
assert_eq!(ctx.r.position(), w.len());
}
/// A one-triangle TESS_3D_Compressed. `full` takes every optional branch: stored normals,
/// colours, line attributes, texture, behaviours. Otherwise `three_t` picks the 3T edge form.
fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) {
compressed_with_angles(w, v, full, three_t, 10);
}
/// [`compressed`] whose `full` form stores `angle_bits`-bit normal angles.
fn compressed_with_angles(w: &mut W, v: u32, full: bool, three_t: bool, angle_bits: u8) {
w.uint(173).bit(false).bit(true).double(0.001);
if v >= 7031 {
w.put(0, 32).put(0, 32).put(0, 32);
}
// point_array: nine 2-bit values.
w.bit(false).uint(9);
for _ in 0..9 {
w.put(2, 8);
}
for _ in 0..9 {
w.bit(false).bit(true);
}
// edge_status_array: the 3T form (Huffman) or the T form.
if full {
w.bit(true).huffman(2, 2, &[(0, 0b10, 2)], &[0, 0, 0]);
} else if three_t {
w.bit(false).uint(3).put(0, 8).put(0, 8).put(0, 8);
} else {
w.bit(false).uint(1).put(0, 8);
}
// triangle_face_array [0]. `full`: four references, so the flagless reference array is
// Huffman; else none.
w.bit(false).uint(1).put(1, 8).bit(false);
if full {
w.uint(4).put(0b1111, 4);
w.huffman(6, 2, &[(1, 0b10, 2), (0, 0b11, 2)], &[1, 0, 0, 0]);
w.put(0, 4);
} else {
w.uint(3).put(0, 3).uint(0);
}
if full {
w.bit(false).put(u64::from(angle_bits), 8).uint(3).put(0b101, 3);
w.bit(true).huffman(10, 2, &[(5, 0b10, 2)], &[5, 5]);
w.bit(true); // is_face_planar
w.bit(true).bit(true).bit(false).uint(5);
for c in [1, 255, 0, 0, 128] {
w.put(c, 8);
}
w.bit(true).bit(false);
} else {
w.bit(true).bit(false).double(0.5).put(0, 8);
w.bit(false).bit(false);
}
w.bit(false).uint(1).put(1, 8).put(0, 8); // line_attribute_array
if full {
w.bit(false).uint(1).put(0, 32).uint(32);
w.uint(1).put(1, 5).bit(true).double(0.5).uint(2);
w.put(0, 32).put(0, 32).bit(false).bit(true);
w.bit(true).bit(false).uint(1).put(1, 8);
} else {
w.bit(true).bit(false);
}
}
#[test]
fn a_compressed_mesh_is_read_to_its_end() {
for (full, three_t) in [(false, false), (false, true), (true, true)] {
for v in [7030, 7031, 8137] {
let mut b = W::default();
compressed(&mut b, v, full, three_t);
let indices = [0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9];
tess_3d(&mut b, v, &Mesh { indices: &indices, faces: &[(0x2 | 0x4, 0, &[1, 1, 4])], ..Mesh::default() });
let w = section(2, &b);
let bytes = w.bytes();
let schema = Schema::default();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, v);
let t = ctx.file_structure_tessellation().unwrap();
assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }, Tessellation::Mesh(_)]), "full={full} v={v}: {t:?}");
assert_eq!(ctx.r.position(), w.len(), "full={full} v={v}");
}
}
}
#[test]
fn refusals_and_damage_are_errors() {
// Edge statuses neither T nor 3T long.
let mut c = W::default();
c.uint(173).bit(false).bit(true).double(0.001);
c.put(0, 32).put(0, 32).put(0, 32);
c.bit(false).uint(0).bit(false).uint(2).put(0, 16);
c.bit(false).uint(1).put(1, 8).bit(false);
assert!(matches!(decode(&section(1, &c), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
// Optimised vertex colours.
let mut o = W::default();
square(&mut o, 175);
o.uint(0).bit(true).bit(false).bit(false).bit(true);
assert!(matches!(decode(&section(1, &o), &Schema::default(), 8137), Err(PrcError::Unsupported(_))));
// A point index that is not a multiple of 3, and one past the end.
for bad in [4, 12] {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, bad, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() });
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
}
// A fan whose count claims a billion points.
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0], faces: &[(0x4, 0, &[1, 0x3FFF_FFFF])], ..Mesh::default() });
assert!(decode(&section(1, &b), &Schema::default(), 8137).is_err());
// A tessellation count far past the data.
let mut w = W::default();
w.uint(305).uint(0).bit(true).uint(u32::MAX);
assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Truncated(_))));
// A wrong entity type where the section's own belongs.
let mut w = W::default();
w.uint(306);
assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Malformed(_))));
}
#[test]
fn every_truncation_of_a_section_is_an_error_not_a_panic() {
let mut b = W::default();
compressed(&mut b, 8137, true, true);
wire(&mut b, true);
let bytes = section(2, &b).bytes();
for end in 0..bytes.len() {
let cut = bytes.get(..end).unwrap();
// The zero padding of the last byte may still decode; anything shorter must not.
let _ = Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation();
}
let cut = bytes.get(..bytes.len() / 2).unwrap();
assert!(Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation().is_err());
}
/// A one-file-structure PRC stream whose tessellation section is `tess`, with an empty
/// globals section schema.
fn prc_file(tess: &[u8]) -> Vec<u8> {
let mut globals = W::default();
globals.uint(0);
prc_file_with(&globals.bytes(), tess)
}
/// As [`prc_file`] with the globals section `globals`.
fn prc_file_with(globals: &[u8], tess: &[u8]) -> Vec<u8> {
use std::io::Write as _;
const HEADER_LEN: usize = 107;
let zlib = |data: &[u8]| {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
};
let u32s = |out: &mut Vec<u8>, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes()));
let sections: [&[u8]; 5] = [globals, b"", tess, b"", b""];
let mut body = b"PRC".to_vec();
u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]);
let mut offs = vec![HEADER_LEN];
for s in sections {
offs.push(HEADER_LEN + body.len());
body.extend(zlib(s));
}
let mf_start = HEADER_LEN + body.len();
body.extend(zlib(b""));
let mf_end = HEADER_LEN + body.len();
let mut out = b"PRC".to_vec();
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]);
u32s(&mut out, &[5, 6, 7, 8, 0, 6]);
u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::<Vec<_>>());
u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]);
out.extend(body);
out
}
#[test]
fn a_prc_file_yields_its_tessellations() {
let f = crate::PrcFile::parse(&prc_file(&textured_square().bytes())).unwrap();
let t = f.file_structures[0].tessellations().unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2], [0, 2, 3]]);
}
#[test]
fn normal_angles_wider_than_16_bits_are_refused_by_name() {
let mut b = W::default();
compressed_with_angles(&mut b, 8137, true, true, 17);
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == "17-bit normal angles"));
}
/// An entity's counted array is charged against the reader's element allowance.
#[test]
fn counted_entity_arrays_are_charged_against_the_allowance() {
let mut w = W::default();
w.uint(3).uint(1).uint(2).uint(3);
let bytes = w.bytes();
let schema = Schema::default();
let read = |n| Ctx::new(BitReader::with_elements(&bytes, n), &schema, 8137).uints("indices");
assert!(matches!(read(2), Err(PrcError::TooManyElements { .. })));
assert_eq!(read(3).unwrap(), [1, 2, 3]);
}
#[test]
fn a_face_behaviour_wider_than_16_bits_is_malformed() {
let r = decode(&styled_face_section(&[1], 0, 0x1_0030), &Schema::default(), 8137);
assert!(matches!(r, Err(PrcError::Malformed(m)) if m == "face behaviour 65584 is wider than 16 bits"));
}
#[test]
fn coordinates_that_are_not_whole_points_are_malformed() {
let mut partial = SQUARE.to_vec();
partial.push(7.0);
let mut b = W::default();
let faces: &[(u32, u32, &[u32])] = &[(0x2, 0, &[1])];
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces, coords: Some(&partial), ..Mesh::default() });
let r = decode(&section(1, &b), &Schema::default(), 8137);
assert!(matches!(r, Err(PrcError::Malformed(m)) if m == "13 coordinates are not whole points"));
// A partial last normal drops the stored normals, as a slot naming none does.
assert_eq!(stored_normals(&[0.0, 0.0, 1.0, 0.0], &[[0, 0, 0]], 1), (Vec::new(), Vec::new()));
}
/// Elements a tessellation section spends from a full allowance.
fn spent(section: &W) -> usize {
let bytes = section.bytes();
let schema = Schema::default();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137);
ctx.file_structure_tessellation().unwrap();
crate::MAX_DECODED_ELEMENTS - ctx.r.elements_left()
}
#[test]
fn faces_reusing_one_index_range_are_charged_for_each_triangle_they_make() {
const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9];
let square = |faces: &[(u32, u32, &[u32])]| {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces, ..Mesh::default() });
spent(&section(1, &b))
};
let one = square(&[(0x2, 0, &[2])]);
let three = square(&[(0x2, 0, &[2]), (0x2, 0, &[2]), (0x2, 0, &[2])]);
// Per extra face: its count entry, its one data element and its two triangles, each
// 12 bytes of corners and 12 of normal slots.
assert_eq!(three - one, 2 * (2 + 2 * 4));
}
#[test]
fn reused_textured_faces_are_charged_for_every_corner_array() {
// The textured square, then one untextured triangle's normal and point slots.
const INDICES: [u32; 24] = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9, 0, 0, 0, 3, 0, 6];
let square = |faces: &[(u32, u32, &[u32])], sets: u32| {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces, textures: sets, uvs: &[0.0; 8], ..Mesh::default() });
spent(&section(1, &b))
};
let face: (u32, u32, &[u32]) = (0x200, 0, &[2]);
let untextured: (u32, u32, &[u32]) = (0x2, 18, &[1]);
let one = square(&[face], 1);
let three = square(&[face, face, face], 1);
// Per extra face: its count, its data element and two triangles of corners, normal slots
// and one texture set, 16 bytes a corner triple.
assert_eq!(three - one, 2 * (2 + 2 * 6));
// One textured face gives every triangle of the mesh a texture triple.
let mixed = square(&[face, untextured], 1);
assert_eq!(mixed - one, 2 + 6);
}
#[test]
fn a_face_is_charged_before_it_is_triangulated() {
// One face claiming a million triangles over a 12-entry index array.
const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9];
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces: &[(0x2, 0, &[1_000_000])], ..Mesh::default() });
let bytes = section(1, &b).bytes();
let schema = Schema::default();
let r = Ctx::new(BitReader::with_elements(&bytes, 1000), &schema, 8137).file_structure_tessellation();
assert!(matches!(r, Err(PrcError::TooManyElements { .. })), "{r:?}");
}
#[test]
fn the_globals_schema_and_the_tessellations_share_one_allowance() {
// A schema of one entry with three tokens: four elements.
let mut globals = W::default();
globals.uint(1).uint(999).uint(3).uint(0).uint(0).uint(0);
let tess = textured_square();
let f = crate::PrcFile::parse(&prc_file_with(&globals.bytes(), &tess.bytes())).unwrap();
let need = spent(&tess) + 4;
let within = |elements| f.file_structures[0].tessellations_within(crate::bits::Budget { elements, ..crate::bits::Budget::FULL });
assert!(within(need).is_ok());
assert!(matches!(within(need - 1), Err(PrcError::TooManyElements { .. })));
}
+282
View File
@@ -0,0 +1,282 @@
//! A `TESS_Face`'s triangulated data turned into triangles [WD 7.8.5.5, 7.8.6].
use super::TriangleStyle;
use crate::PrcError;
use crate::entity::malformed;
/// A fan/strip count's point-count bits; bit 30 is `NORMAL_Single` [WD 7.8.6.2].
const COUNT_MASK: u32 = 0x3FFF_FFFF;
const NORMAL_SINGLE: u32 = 0x4000_0000;
/// Texture-coordinate sets a face may carry. Each set costs a slot per point and a corner
/// triple per triangle of the whole mesh, so the count is capped.
pub(super) const MAX_TEXTURE_SETS: usize = 8;
pub(super) struct Face {
pub(super) flags: u32,
pub(super) start: usize,
pub(super) data: Vec<u32>,
pub(super) textures: usize,
/// `line_attributes`, each a style index + 1: none = the owner's graphics, one = the
/// whole face's, more = one per triangulation entity [WD 7.8.6].
pub(super) styles: Vec<u32>,
/// The behaviour bits read when `styles` is not empty.
pub(super) behaviour: u16,
}
/// A `used_entities_flag` block's shape [WD 7.8.5.5].
#[derive(Clone, Copy)]
enum Shape {
Triangles,
Fan,
Strip,
}
/// One `used_entities_flag` block.
#[derive(Clone, Copy)]
struct Block {
bit: u32,
shape: Shape,
/// One normal per entity rather than per point.
one_normal: bool,
textured: bool,
}
const fn block(bit: u32, shape: Shape, one_normal: bool, textured: bool) -> Block {
Block { bit, shape, one_normal, textured }
}
/// The blocks in stream order, low bit first [WD 7.8.5.5].
const BLOCKS: [Block; 12] = [
block(0x0002, Shape::Triangles, false, false),
block(0x0004, Shape::Fan, false, false),
block(0x0008, Shape::Strip, false, false),
block(0x0020, Shape::Triangles, true, false),
block(0x0040, Shape::Fan, true, false),
block(0x0080, Shape::Strip, true, false),
block(0x0200, Shape::Triangles, false, true),
block(0x0400, Shape::Fan, false, true),
block(0x0800, Shape::Strip, false, true),
block(0x2000, Shape::Triangles, true, true),
block(0x4000, Shape::Fan, true, true),
block(0x8000, Shape::Strip, true, true),
];
/// One triangulation entity's index slots: per point its position, normal slot (when the
/// array stores normals) and texture slots.
#[derive(Default)]
struct Slots {
verts: Vec<u32>,
normals: Vec<u32>,
/// Per texture set, each point's slot.
textures: Vec<Vec<u32>>,
}
/// What a mesh's triangles carry besides positions, built in step with them: corner normal
/// slots and, per texture set, corner texture slots (`None` for a triangle whose face stores
/// fewer sets).
#[derive(Default)]
pub(super) struct Corners {
pub(super) normals: Vec<[u32; 3]>,
pub(super) textures: Vec<Vec<Option<[u32; 3]>>>,
}
impl Face {
/// Entity sizes per set block, from `TriangulatedData` [WD 7.8.6]. A triangle block is
/// one size word (its triangle count); fans and strips list a vertex count each.
fn entities(&self) -> Result<Vec<(Block, Vec<u32>)>, PrcError> {
let mut d = self.data.iter().copied();
let mut next = || d.next().ok_or_else(|| malformed("triangulated data ends early".into()));
let mut out = Vec::new();
for b in BLOCKS {
if self.flags & b.bit == 0 {
continue;
}
let sizes = match b.shape {
Shape::Triangles => vec![next()?],
Shape::Fan | Shape::Strip => {
let n = next()? as usize;
if n > self.data.len() {
return Err(malformed("triangulated data ends early".into()));
}
(0..n).map(|_| next()).collect::<Result<_, _>>()?
}
};
out.push((b, sizes));
}
Ok(out)
}
/// Points the face's index slots name: the vertex-colour count (pdf-issues #820).
pub(super) fn point_count(&self) -> Result<usize, PrcError> {
let mut n = 0usize;
for (b, sizes) in self.entities()? {
for s in sizes {
let s = (s & COUNT_MASK) as usize;
n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s.saturating_mul(3) } else { s });
}
}
Ok(n)
}
/// Triangles [`Self::triangulate`] makes: one per triangle-block count, two fewer than the
/// points of each fan and strip.
pub(super) fn triangle_count(&self) -> Result<usize, PrcError> {
let mut n = 0usize;
for (b, sizes) in self.entities()? {
for s in sizes {
let s = (s & COUNT_MASK) as usize;
n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s } else { s.saturating_sub(2) });
}
}
Ok(n)
}
/// Texture sets the face's triangles carry: its count when one of its blocks is textured,
/// at most [`MAX_TEXTURE_SETS`] (a face past it is refused when triangulated).
pub(super) fn texture_sets(&self) -> usize {
if BLOCKS.iter().any(|b| b.textured && self.flags & b.bit != 0) { self.textures.min(MAX_TEXTURE_SETS) } else { 0 }
}
/// Emit the face's triangles into `out`; `normals` = the index array stores normal slots,
/// in which case each triangle's corner normal slots go to `corners`, as do its corner
/// texture slots. Returns the triangles each triangulation entity emitted, in order: one
/// entity per triangle of a triangle block, per fan, per strip [WD 7.8.6].
pub(super) fn triangulate(
&self,
indices: &[u32],
normals: bool,
n_points: usize,
out: &mut Vec<[u32; 3]>,
corners: &mut Corners,
) -> Result<Vec<usize>, PrcError> {
if self.textures > MAX_TEXTURE_SETS {
return Err(malformed(format!("{} texture-coordinate sets on a face", self.textures)));
}
let mut entities = Vec::new();
let mut slots = indices.get(self.start..).unwrap_or(&[]).iter().copied();
for (b, sizes) in self.entities()? {
let t = if b.textured { self.textures } else { 0 };
for word in sizes {
let count = (word & COUNT_MASK) as usize;
// Points in this entity, and whether each carries its own normal slot.
let (points, per_point_normal) = match b.shape {
Shape::Triangles => (count.saturating_mul(3), !b.one_normal),
Shape::Fan | Shape::Strip => (count, !b.one_normal || word & NORMAL_SINGLE == 0),
};
if points.saturating_mul(t.saturating_add(1)) > indices.len() {
return Err(malformed("face runs past the triangulated index array".into()));
}
let s = read_slots(&mut slots, b.shape, points, t, normals.then_some(per_point_normal), n_points)?;
let made = emit(b.shape, &s, out, corners);
match b.shape {
Shape::Triangles => entities.extend(std::iter::repeat_n(1, made)),
Shape::Fan | Shape::Strip => entities.push(made),
}
}
}
Ok(entities)
}
/// Append one [`TriangleStyle`] per triangle of the face, whose entities emitted
/// `entities[i]` triangles each. Entities past the face's styles inherit (style 0).
pub(super) fn styles_into(&self, entities: &[usize], out: &mut Vec<TriangleStyle>) {
let whole = match self.styles.as_slice() {
[] => Some(0),
[one] => Some(*one),
_ => None,
};
for (i, &n) in entities.iter().enumerate() {
let style = whole.unwrap_or_else(|| self.styles.get(i).copied().unwrap_or(0));
out.extend(std::iter::repeat_n(TriangleStyle { style, behaviour: self.behaviour }, n));
}
}
}
/// The 8-byte elements of [`crate::MAX_DECODED_ELEMENTS`] one triangle holds: its corners
/// (12 bytes), its normal-slot corners when the index array stores them (12), a corner triple
/// per texture set (16 each) and its style when the mesh is styled (8).
pub(super) fn triangle_elements(normals: bool, sets: usize, styled: bool) -> usize {
let normals = if normals { 2 } else { 0 };
2usize.saturating_add(normals).saturating_add(sets.saturating_mul(2)).saturating_add(usize::from(styled))
}
/// One entity's `points` index slots. `normals` is `None` when the array stores no normal
/// slots, else whether every point has its own (rather than one per entity).
fn read_slots(
slots: &mut impl Iterator<Item = u32>,
shape: Shape,
points: usize,
texture_sets: usize,
normals: Option<bool>,
n_points: usize,
) -> Result<Slots, PrcError> {
let mut take = || slots.next().ok_or_else(|| malformed("face runs past the triangulated index array".into()));
// The caller bounded points * (texture_sets + 1) by the index array's length.
let mut s = Slots { textures: vec![Vec::with_capacity(points); texture_sets], ..Slots::default() };
let mut current = 0;
for k in 0..points {
let new_entity = match shape {
Shape::Triangles => k % 3 == 0,
Shape::Fan | Shape::Strip => k == 0,
};
if let Some(per_point) = normals {
if per_point || new_entity {
current = take()?;
}
s.normals.push(current);
}
for set in &mut s.textures {
set.push(take()?);
}
let p = take()?;
if !p.is_multiple_of(3) || p as usize / 3 >= n_points {
return Err(malformed(format!("point index {p} is not a point")));
}
s.verts.push(p / 3);
}
Ok(s)
}
/// Append the triangles of one entity's `slots` to `out` and `corners`, keeping every
/// texture set as long as `out`. Returns how many it made.
fn emit(shape: Shape, slots: &Slots, out: &mut Vec<[u32; 3]>, corners: &mut Corners) -> usize {
let before = out.len();
assemble(shape, &slots.verts, out);
assemble(shape, &slots.normals, &mut corners.normals);
let made = out.len().saturating_sub(before);
while corners.textures.len() < slots.textures.len() {
corners.textures.push(vec![None; before]);
}
for (k, set) in corners.textures.iter_mut().enumerate() {
match slots.textures.get(k) {
Some(own) => {
let mut tris = Vec::with_capacity(made);
assemble(shape, own, &mut tris);
set.extend(tris.into_iter().map(Some));
}
None => set.extend(std::iter::repeat_n(None, made)),
}
}
made
}
/// Append the triangles `shape` makes of `verts`.
fn assemble(shape: Shape, verts: &[u32], out: &mut Vec<[u32; 3]>) {
match shape {
Shape::Triangles => out.extend(verts.as_chunks::<3>().0.iter().copied()),
Shape::Fan => {
if let Some((&c, rim)) = verts.split_first() {
out.extend(rim.windows(2).filter_map(|w| match w {
[a, b] => Some([c, *a, *b]),
_ => None,
}));
}
}
Shape::Strip => out.extend(verts.windows(3).enumerate().filter_map(|(k, w)| match w {
[a, b, c] if k % 2 == 0 => Some([*a, *b, *c]),
[a, b, c] => Some([*b, *a, *c]),
_ => None,
})),
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Test-only PRC bit writer: the inverse of [`crate::BitReader`].
#![allow(clippy::expect_used)]
use crate::acof;
/// An MSB-first bit sink for building test streams.
#[derive(Debug, Default, Clone)]
pub(crate) struct W {
bits: Vec<bool>,
}
impl W {
/// The low `n` bits of `v` (`n` ≤ 64), most significant first.
pub(crate) fn put(&mut self, v: u64, n: u32) -> &mut Self {
for i in (0..n).rev() {
self.bits.push((v >> i) & 1 == 1);
}
self
}
/// One bit; a PRC `Boolean`.
pub(crate) fn bit(&mut self, b: bool) -> &mut Self {
self.bits.push(b);
self
}
/// Bits written so far.
pub(crate) fn len(&self) -> usize {
self.bits.len()
}
/// Every bit of `other`, unaligned.
pub(crate) fn append(&mut self, other: &Self) -> &mut Self {
self.bits.extend_from_slice(&other.bits);
self
}
/// An `UnsignedInteger` [WD 11.10].
pub(crate) fn uint(&mut self, mut v: u32) -> &mut Self {
while v != 0 {
self.bit(true).put(u64::from(v & 0xff), 8);
v >>= 8;
}
self.bit(false)
}
/// An `Integer` [WD 11.11]: two's-complement groups, low first, each followed by a
/// continue bit.
pub(crate) fn int(&mut self, v: i32) -> &mut Self {
if v == 0 {
return self.bit(false);
}
self.bit(true);
let mut v = i64::from(v);
loop {
let group = v & 0xff;
self.put(group.cast_unsigned(), 8);
v >>= 8;
let done = (v == 0 && group & 0x80 == 0) || (v == -1 && group & 0x80 != 0);
self.bit(!done);
if done {
return self;
}
}
}
/// A `String` [WD 11.4]: presence bit, byte count, raw UTF-8 bytes.
pub(crate) fn string(&mut self, s: Option<&str>) -> &mut Self {
let Some(s) = s else {
return self.bit(false);
};
self.bit(true).uint(u32::try_from(s.len()).expect("a short test string"));
for b in s.bytes() {
self.put(u64::from(b), 8);
}
self
}
/// A finite `Double` [WD 11.17]: zero as the two-bit zero code; anything else as its
/// exponent row, the sign, then the mantissa as literal bytes.
pub(crate) fn double(&mut self, v: f64) -> &mut Self {
if v == 0.0 {
return self.put(0b01, 2);
}
let bits = v.to_bits();
let exponent = u32::try_from((bits >> 52) & 0x7ff).expect("11 bits");
let (n, c) = acof::exponent_code(exponent).expect("the Double table has a row per exponent");
let le = bits.to_le_bytes();
self.put(u64::from(c), n).bit(v < 0.0).bit(true).put(u64::from(le[6] & 0x0f), 4);
for b in le[..6].iter().rev() {
self.bit(true).put(u64::from(*b), 8);
}
self
}
/// A Huffman container [WD 12.2]: `leaves` are `(symbol, stored code, length)`, each value
/// is emitted as its leaf's code.
pub(crate) fn huffman(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], values: &[i32]) -> &mut Self {
let mask = (1u32 << nbits) - 1;
let codes: Vec<(u32, u32)> = values
.iter()
.map(|&v| {
let l = leaves.iter().find(|l| l.0 & mask == v.cast_unsigned() & mask).expect("a leaf per value");
(l.1, l.2)
})
.collect();
self.huffman_raw(nbits, len_width, leaves, &codes)
}
/// As [`Self::huffman`] with each element's `(code, length)` given.
pub(crate) fn huffman_raw(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], codes: &[(u32, u32)]) -> &mut Self {
let mut blob = Self::huffman_head(nbits, len_width, leaves);
lsb(&mut blob, u32::try_from(codes.len()).expect("a short test array"), 32);
for &(c, l) in codes {
for i in (0..l).rev() {
blob.push((c >> i) & 1 == 1);
}
}
self.huffman_words(blob)
}
/// A Huffman container whose element count is `count` with no elements written.
pub(crate) fn huffman_count(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], count: u32) -> &mut Self {
let mut blob = Self::huffman_head(nbits, len_width, leaves);
lsb(&mut blob, count, 32);
self.huffman_words(blob)
}
fn huffman_head(nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)]) -> Vec<bool> {
let mut blob = Vec::new();
lsb(&mut blob, u32::try_from(leaves.len()).expect("a short test table"), nbits + 1);
lsb(&mut blob, len_width, 8);
for &(s, c, l) in leaves {
lsb(&mut blob, s, nbits);
lsb(&mut blob, l, len_width);
lsb(&mut blob, c, l);
}
blob
}
/// The payload as whole 32-bit words of LSB-first bytes, then the last word's used bits.
fn huffman_words(&mut self, mut blob: Vec<bool>) -> &mut Self {
let bits = blob.len();
let words = bits.div_ceil(32);
blob.resize(words * 32, false);
self.uint(u32::try_from(words).expect("a short test payload"));
for byte in blob.chunks(8) {
let b = byte.iter().enumerate().fold(0u64, |a, (i, &x)| a | (u64::from(x) << i));
self.put(b, 8);
}
self.uint(u32::try_from(bits - (words - 1) * 32).expect("at most 32"))
}
/// The bits packed MSB-first, the last byte zero-padded.
pub(crate) fn bytes(&self) -> Vec<u8> {
self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect()
}
}
/// The low `n` bits of `v`, least significant first.
fn lsb(blob: &mut Vec<bool>, v: u32, n: u32) {
for i in 0..n {
blob.push((v >> i) & 1 == 1);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! What a tree carries beside its geometry, read past and not modelled: markup data (PMI)
//! and views [WD 7.3.10.4, 7.7], and a scene's lights and clipping planes [WD 7.5.15-7.5.18,
//! 7.11.13]. The tree section has no length to skip by, so each is read field by field.
use super::{
AMBIENT_LIGHT, ANNOTATION_ITEM, ANNOTATION_REFERENCE, ANNOTATION_SET, DIRECTIONAL_LIGHT, LIGHT_INTENSITY_FROM, MARKUP_LINKED_ITEM, MAX_DEPTH,
MKP_LEADER, MKP_MARKUP, MKP_VIEW, POINT_LIGHT, SPOT_LIGHT, SURF_PLANE, VIEW_FLAGS_FROM,
};
use crate::PrcError;
use crate::entity::{Ctx, malformed};
impl Ctx<'_, '_> {
/// `MarkupData` [WD 7.3.10.4]: linked items, leaders, markups, then annotation entities.
pub(super) fn markup_data(&mut self) -> Result<(), PrcError> {
let n = self.count(1, "markup linked items")?;
for _ in 0..n {
self.markup_linked_item()?;
}
let n = self.count(1, "leaders")?;
for _ in 0..n {
self.leader()?;
}
let n = self.count(1, "markups")?;
for _ in 0..n {
self.mkp_markup()?;
}
let n = self.count(1, "annotation entities")?;
for _ in 0..n {
self.annotation_entity(0)?;
}
Ok(())
}
/// A counted list of `MKP_View`s.
pub(super) fn views(&mut self) -> Result<(), PrcError> {
let n = self.count(1, "views")?;
for _ in 0..n {
self.view()?;
}
Ok(())
}
/// A counted list of `ReferenceOnPRCBase`s.
fn references(&mut self, what: &'static str) -> Result<(), PrcError> {
let n = self.count(1, what)?;
for _ in 0..n {
self.reference_on_prc_base()?;
}
Ok(())
}
/// `MISC_MarkupLinkedItem` (204) [WD 7.4.5].
// prc-rs: a `has_reference_data` flag and its reference data follow the content entity
// reference (pdf-issues #722).
fn markup_linked_item(&mut self) -> Result<(), PrcError> {
self.expect_type(MARKUP_LINKED_ITEM)?;
self.base_with_graphics()?;
self.r.unsigned_integer()?; // local coordinate system + 1
if self.r.bit()? {
self.reference_data()?;
}
if self.r.bit()? {
self.reference_data()?;
}
for _ in 0..4 {
self.r.bit()?; // show and delete, markup and leader
}
self.schema.skip_added_fields(MARKUP_LINKED_ITEM, &mut self.r)?;
self.user_data()
}
/// `MKP_Leader` (503) [WD 7.7.5; pdf-issues #698].
// prc-rs: the leader's field order.
fn leader(&mut self) -> Result<(), PrcError> {
self.expect_type(MKP_LEADER)?;
self.base_with_graphics()?;
for _ in 0..2 {
if self.r.bit()? {
self.reference_on_prc_base()?; // first, second linked item
}
}
self.r.unsigned_integer()?; // tessellation + 1
self.schema.skip_added_fields(MKP_LEADER, &mut self.r)?;
self.user_data()
}
/// `MKP_Markup` (502) [WD 7.7.4].
// prc-rs: the markup's field order.
fn mkp_markup(&mut self) -> Result<(), PrcError> {
self.expect_type(MKP_MARKUP)?;
self.base_with_graphics()?;
self.r.unsigned_integer()?; // type
self.r.unsigned_integer()?; // subtype
self.references("markup linked items")?;
self.references("markup leaders")?;
self.r.unsigned_integer()?; // tessellation + 1
self.schema.skip_added_fields(MKP_MARKUP, &mut self.r)?;
self.user_data()
}
/// An annotation item (504), set (505) or reference (506) [WD 7.7.6-7.7.8]; sets nest.
// prc-rs: the annotation entities' field orders.
fn annotation_entity(&mut self, depth: usize) -> Result<(), PrcError> {
if depth > MAX_DEPTH {
return Err(malformed("annotation sets nest too deep".into()));
}
let t = self.r.unsigned_integer()?;
if !matches!(t, ANNOTATION_ITEM | ANNOTATION_SET | ANNOTATION_REFERENCE) {
return Err(malformed(format!("entity type {t} as an annotation")));
}
self.base_with_graphics()?;
match t {
ANNOTATION_ITEM => self.reference_on_prc_base()?,
ANNOTATION_SET => {
let n = self.count(1, "annotation set")?;
for _ in 0..n {
self.annotation_entity(depth + 1)?;
}
}
_ => self.references("annotation reference")?,
}
self.schema.skip_added_fields(t, &mut self.r)?;
self.user_data()
}
/// `MKP_View` (501) [WD 7.7.3].
// prc-rs: the view's field order, and its flags, linked items and filters from version
// 8137.
fn view(&mut self) -> Result<(), PrcError> {
self.expect_type(MKP_VIEW)?;
self.base_with_graphics()?;
self.references("view annotations")?;
self.plane()?;
if self.r.bit()? {
self.scene_display_parameters()?;
}
if self.version >= VIEW_FLAGS_FROM {
for _ in 0..3 {
self.r.bit()?; // annotation view, default view, direction
}
self.references("view linked items")?;
let n = self.count(1, "view filters")?;
for _ in 0..n {
self.filter()?;
}
}
self.schema.skip_added_fields(MKP_VIEW, &mut self.r)?;
self.user_data()
}
/// `SURF_Plane` (86) [WD 7.11.13; pdf-issues #658]: `ContentSurface` [WD 7.11.3.2], a
/// `Transformation3D` with no presence flag, the `Domain` [WD 8.2.4] and the
/// parameterisation's four coefficients.
// prc-rs: the plane's field order.
pub(super) fn plane(&mut self) -> Result<(), PrcError> {
self.expect_type(SURF_PLANE)?;
if self.r.bit()? {
self.content_prc_base()?; // base geometry: attributes and name
self.r.unsigned_integer()?; // identifier
}
self.r.unsigned_integer()?; // extend type
self.transformation_3d()?;
for _ in 0..8 {
self.r.double()?; // domain (4), coefficients (4)
}
self.schema.skip_added_fields(SURF_PLANE, &mut self.r)
}
/// A light (731-734) [WD 7.5.15-7.5.18].
// prc-rs: the lights' field orders, and a directional light's intensity from version 8030
// (pdf-issues #745).
pub(super) fn light(&mut self) -> Result<(), PrcError> {
let t = self.r.unsigned_integer()?;
if !(AMBIENT_LIGHT..=SPOT_LIGHT).contains(&t) {
return Err(malformed(format!("entity type {t} as a light")));
}
self.content_prc_ref_base()?;
for _ in 0..3 {
self.r.unsigned_integer()?; // ambient, diffuse, specular colour + 1
}
if matches!(t, POINT_LIGHT | SPOT_LIGHT) {
self.vector3()?; // location
for _ in 0..3 {
self.r.double()?; // attenuation
}
}
if matches!(t, DIRECTIONAL_LIGHT | SPOT_LIGHT) {
self.vector3()?; // direction
}
if t == DIRECTIONAL_LIGHT && self.version >= LIGHT_INTENSITY_FROM {
self.r.double()?;
}
if t == SPOT_LIGHT {
self.r.double()?; // fall-off angle
self.r.double()?; // fall-off exponent
}
self.schema.skip_added_fields(t, &mut self.r)
}
}
+280
View File
@@ -0,0 +1,280 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::tests::{Occ, PLAIN, S_RED, base, graphics, part_with, plate, ref_base, single};
use crate::PrcError;
use crate::bits::BitReader;
use crate::entity::Ctx;
use crate::testw::W;
use crate::{Schema, Tessellation};
/// Read after each entity, to show it was read to its last bit and no further.
const SENTINEL: u32 = 0x5A5A;
/// Runs `read` over `w` then the sentinel, at authoring version `version`.
fn read_at(w: &W, version: u32, read: impl FnOnce(&mut Ctx<'_, '_>) -> Result<(), PrcError>) -> Result<(), PrcError> {
let mut all = W::default();
all.append(w).uint(SENTINEL);
let bytes = all.bytes();
let schema = Schema::default();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, version);
read(&mut ctx)?;
assert_eq!(ctx.r.unsigned_integer()?, SENTINEL, "read past the wrong number of bits");
Ok(())
}
fn read(w: &W, read: impl FnOnce(&mut Ctx<'_, '_>) -> Result<(), PrcError>) -> Result<(), PrcError> {
read_at(w, 8137, read)
}
/// Each truncation of `w` is an error, never a panic.
fn truncations_fail(w: &W, read: impl Fn(&mut Ctx<'_, '_>) -> Result<(), PrcError>) {
let bytes = w.bytes();
let schema = Schema::default();
for end in 0..bytes.len().saturating_sub(1) {
let mut ctx = Ctx::new(BitReader::new(&bytes[..end]), &schema, 8137);
assert!(read(&mut ctx).is_err(), "cut at {end}");
}
}
fn reference(w: &mut W, uid: u32) {
w.uint(205).uint(237).bit(true).uint(uid);
}
fn markup_data(linked: &[W], leaders: &[W], markups: &[W], annotations: &[W]) -> W {
let mut w = W::default();
for list in [linked, leaders, markups, annotations] {
w.uint(u32::try_from(list.len()).unwrap());
for e in list {
w.append(e);
}
}
w
}
fn linked_item() -> W {
let mut w = W::default();
w.uint(204);
graphics(&mut w, None, 1, PLAIN);
w.uint(0).bit(true);
reference(&mut w, 7);
w.bit(true).uint(206).uint(149).bit(true).bit(true).uint(1).uint(1).uint(1).uint(0);
w.bit(true).bit(false).bit(true).bit(false).uint(0);
w
}
fn leader() -> W {
let mut w = W::default();
w.uint(503);
graphics(&mut w, None, 2, PLAIN);
w.bit(true);
reference(&mut w, 3);
w.bit(false).uint(4).uint(0);
w
}
fn markup() -> W {
let mut w = W::default();
w.uint(502);
graphics(&mut w, Some("Note"), 3, PLAIN);
w.uint(1).uint(2).uint(1);
reference(&mut w, 7);
w.uint(2);
reference(&mut w, 2);
reference(&mut w, 2);
w.uint(3).uint(0);
w
}
fn annotation(t: u32, inner: impl FnOnce(&mut W)) -> W {
let mut w = W::default();
w.uint(t);
graphics(&mut w, None, 4, PLAIN);
inner(&mut w);
w.uint(0);
w
}
fn annotation_item() -> W {
annotation(504, |w| reference(w, 3))
}
fn annotation_set(members: &[W]) -> W {
annotation(505, |w| {
w.uint(u32::try_from(members.len()).unwrap());
for m in members {
w.append(m);
}
})
}
fn annotation_reference() -> W {
annotation(506, |w| {
w.uint(1);
reference(w, 3);
})
}
fn plane(w: &mut W) {
w.uint(86).bit(true);
base(w, Some("Front"));
w.uint(9).uint(0).put(0x01, 8);
for v in [1., 2., 3., 0., 0., 1., 1., 1., 1., 0., 0.] {
w.double(v);
}
}
fn light(t: u32) -> W {
let mut w = W::default();
w.uint(t);
ref_base(&mut w, None, 5);
w.uint(1).uint(2).uint(3);
let doubles = match t {
732 => 6,
733 => 4,
734 => 11,
_ => 0,
};
for _ in 0..doubles {
w.double(0.5);
}
w
}
/// A scene with `lights` and `planes` clipping planes; `is_absolute` is written from 8137.
fn scene(lights: &[W], planes: usize, version: u32) -> W {
let mut w = W::default();
w.uint(741);
ref_base(&mut w, None, 6);
w.bit(true).uint(u32::try_from(lights.len()).unwrap());
for l in lights {
w.append(l);
}
w.bit(false).bit(false).uint(u32::try_from(planes).unwrap());
for _ in 0..planes {
plane(&mut w);
}
w.uint(0).uint(0).uint(0);
if version >= 8137 {
w.bit(true);
}
w
}
fn filter(w: &mut W) {
w.uint(320);
ref_base(w, None, 8);
w.bit(true).bit(true).uint(0).bit(true).uint(0).uint(0);
}
fn view(version: u32) -> W {
let mut w = W::default();
w.uint(501);
graphics(&mut w, Some("Top"), 7, PLAIN);
w.uint(1);
reference(&mut w, 3);
plane(&mut w);
w.bit(true).append(&scene(&[light(731)], 1, version));
if version >= 8137 {
w.bit(true).bit(false).bit(true).uint(1);
reference(&mut w, 7);
w.uint(1);
filter(&mut w);
}
w.uint(0);
w
}
fn views(vs: &[W]) -> W {
let mut w = W::default();
w.uint(u32::try_from(vs.len()).unwrap());
for v in vs {
w.append(v);
}
w
}
#[test]
fn a_markup_linked_item_is_read_past() {
let w = markup_data(&[linked_item()], &[], &[], &[]);
read(&w, |c| c.markup_data()).unwrap();
truncations_fail(&w, |c| c.markup_data());
}
#[test]
fn a_leader_is_read_past() {
let w = markup_data(&[], &[leader()], &[], &[]);
read(&w, |c| c.markup_data()).unwrap();
truncations_fail(&w, |c| c.markup_data());
}
#[test]
fn a_markup_is_read_past() {
let w = markup_data(&[], &[], &[markup()], &[]);
read(&w, |c| c.markup_data()).unwrap();
truncations_fail(&w, |c| c.markup_data());
}
#[test]
fn annotation_items_sets_and_references_are_read_past() {
let set = annotation_set(&[annotation_item(), annotation_set(&[annotation_reference()])]);
let w = markup_data(&[], &[], &[], &[annotation_item(), set, annotation_reference()]);
read(&w, |c| c.markup_data()).unwrap();
truncations_fail(&w, |c| c.markup_data());
}
#[test]
fn annotation_sets_nested_past_the_ceiling_are_an_error() {
let mut e = annotation_item();
for _ in 0..=super::MAX_DEPTH {
e = annotation_set(&[e]);
}
let w = markup_data(&[], &[], &[], &[e]);
assert!(matches!(read(&w, |c| c.markup_data()), Err(PrcError::Malformed(_))));
}
#[test]
fn an_annotation_of_another_type_is_an_error() {
let w = markup_data(&[], &[], &[], &[annotation(502, |_| {})]);
assert!(matches!(read(&w, |c| c.markup_data()), Err(PrcError::Malformed(_))));
}
#[test]
fn a_view_is_read_past_with_its_flags_only_from_8137() {
let w = views(&[view(8137)]);
read(&w, |c| c.views()).unwrap();
truncations_fail(&w, |c| c.views());
read_at(&views(&[view(8030)]), 8030, |c| c.views()).unwrap();
}
#[test]
fn scene_lights_of_each_kind_are_read_past() {
let w = scene(&[light(731), light(732), light(733), light(734)], 0, 8137);
read(&w, |c| c.scene_display_parameters()).unwrap();
truncations_fail(&w, |c| c.scene_display_parameters());
let mut old = W::default();
old.uint(733);
ref_base(&mut old, None, 5);
old.uint(1).uint(2).uint(3).double(0.).double(0.).double(1.);
read_at(&old, 8020, |c| c.light()).unwrap();
}
#[test]
fn scene_clipping_planes_are_read_past() {
let w = scene(&[], 2, 8137);
read(&w, |c| c.scene_display_parameters()).unwrap();
truncations_fail(&w, |c| c.scene_display_parameters());
}
#[test]
fn a_tree_with_markups_and_views_still_places_its_parts() {
let mut tail = markup_data(&[linked_item()], &[leader()], &[markup()], &[annotation_set(&[annotation_item()])]);
tail.append(&views(&[view(8137)]));
let part = part_with(Some("Plate"), PLAIN, 1, |w| super::tests::poly_item(w, 7, 0, 1, (S_RED, super::SHOW)), &tail);
let occs = [Occ { name: Some("Asm"), sons: vec![1], markups: Some(tail.clone()), ..Occ::default() }, Occ { part: 1, ..Occ::default() }];
let f = single(&[part, plate(S_RED)], &occs);
let nodes = f.model_tree().unwrap();
assert_eq!(nodes.len(), 2);
let p = f.placements().unwrap();
assert_eq!(p.len(), 1);
assert!(matches!(f.file_structures[0].tessellations().unwrap().first(), Some(Tessellation::Mesh(_))));
}
+329
View File
@@ -0,0 +1,329 @@
//! The product tree: the tree section (`FileStructureTree`, 304), the globals' styles and
//! coordinate systems (303) and the model file (301), walked into placed tessellations
//! [WD 7.3.3-7.3.11, 7.5, 7.6].
//!
//! Markups (PMI), views, scene lights and clipping planes are read past and not modelled.
// prc-rs: where the working draft leaves an entity's field order open, the order here is
// prc-rs's format description's; each such entity's reader carries its own marker.
use std::sync::Arc;
use crate::bits::{BitReader, Budget};
use crate::container::offset;
use crate::entity::{Ctx, malformed};
use crate::geometry::{Matrix, Vec3};
use crate::{PrcError, PrcFile, Schema, SectionKind, UniqueId};
mod markup;
mod node;
mod overrides;
mod read;
mod style;
mod walk;
pub use node::{ModelNode, NameSource};
pub use overrides::EntityOverrides;
use overrides::{Override, Target};
pub(crate) use style::Graphics;
use style::{Globals, Paint, resolve_style};
pub use style::{Rgba, StyleAlpha};
use walk::{At, Walk};
const BASE_WITH_GRAPHICS: u32 = 2;
const MODEL_FILE: u32 = 301;
const FILE_STRUCTURE: u32 = 302;
const FILE_STRUCTURE_GLOBALS: u32 = 303;
const FILE_STRUCTURE_TREE: u32 = 304;
const PRODUCT_OCCURRENCE: u32 = 310;
const PART_DEFINITION: u32 = 311;
const FILTER: u32 = 320;
const SURF_PLANE: u32 = 86;
const CARTESIAN_TRANSFORMATION: u32 = 202;
const ENTITY_REFERENCE: u32 = 203;
const MARKUP_LINKED_ITEM: u32 = 204;
const REFERENCE_ON_PRC_BASE: u32 = 205;
const REFERENCE_ON_TOPOLOGY: u32 = 206;
const GENERAL_TRANSFORMATION: u32 = 207;
const RI_BREP_MODEL: u32 = 232;
const RI_CURVE: u32 = 233;
const RI_DIRECTION: u32 = 234;
const RI_PLANE: u32 = 235;
const RI_POINT_SET: u32 = 236;
const RI_POLY_BREP_MODEL: u32 = 237;
const RI_POLY_WIRE: u32 = 238;
const RI_SET: u32 = 239;
const RI_COORDINATE_SYSTEM: u32 = 240;
const MKP_VIEW: u32 = 501;
const MKP_MARKUP: u32 = 502;
const MKP_LEADER: u32 = 503;
const ANNOTATION_ITEM: u32 = 504;
const ANNOTATION_SET: u32 = 505;
const ANNOTATION_REFERENCE: u32 = 506;
const STYLE: u32 = 701;
const MATERIAL: u32 = 702;
const PICTURE: u32 = 703;
const TEXTURE_APPLICATION: u32 = 711;
const TEXTURE_DEFINITION: u32 = 712;
const TEXTURE_TRANSFORMATION: u32 = 713;
const LINE_PATTERN: u32 = 721;
const AMBIENT_LIGHT: u32 = 731;
const POINT_LIGHT: u32 = 732;
const DIRECTIONAL_LIGHT: u32 = 733;
const SPOT_LIGHT: u32 = 734;
const SCENE_DISPLAY_PARAMETERS: u32 = 741;
const CAMERA: u32 = 742;
/// prc-rs: a scene's `is_absolute` exists from this authoring version.
const ABSOLUTE_SCENE_FROM: u32 = 8137;
/// prc-rs: a view's flags, linked items and filters exist from this authoring version.
const VIEW_FLAGS_FROM: u32 = 8137;
/// prc-rs: a directional light's intensity exists from this authoring version.
const LIGHT_INTENSITY_FROM: u32 = 8030;
/// `TextureDefinition` mapping type "operator" and function "blend", 0-based
/// (pdf-issues #485).
const TEXTURE_MAPPING_OPERATOR: i32 = 3;
const TEXTURE_FUNCTION_BLEND: i32 = 3;
/// Texture application mode: alpha test [WD 7.5.7].
const TEXTURE_ALPHA_TEST: u8 = 0x02;
/// Ceiling on `RI_Set` nesting, the occurrence walk's depth and a prototype chain's length.
const MAX_DEPTH: usize = 64;
/// Ceiling on occurrences visited in one walk: prototypes share subtrees, so a small file can
/// describe an exponential walk.
const MAX_VISITS: usize = 1 << 20;
/// Ceiling on placements one walk yields.
pub const MAX_PLACEMENTS: usize = 1 << 20;
/// `GraphicsContent` behaviour bits [WD 7.2.4.2].
const SHOW: u16 = 0x0001;
const SON_HERIT_COLOR: u16 = 0x0008;
const FATHER_HERIT_COLOR: u16 = 0x0010;
const REMOVED: u16 = 0x2000;
/// `product_behavior` SUPPRESSED [WD 7.3.10].
const SUPPRESSED: u8 = 0x01;
/// Per file structure, entry `b` is the paint of biased style `b`.
type Palettes = Arc<[Arc<[Option<Paint>]>]>;
/// A tessellation drawn at a place in the model: world = [`Self::matrix`] × the
/// tessellation's own coordinates.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Placement {
/// Index into [`PrcFile::file_structures`].
pub file_structure: usize,
/// Index into that file structure's [`tessellations`](crate::FileStructure::tessellations),
/// as the tree stores it: [`PrcFile::placements`] does not decode that section, so an
/// index past its end is the caller's to refuse.
pub tessellation: usize,
/// The occurrence path's locations, then the representation item's coordinate systems.
pub matrix: Matrix,
/// The colour of the style the tree resolves for this item; `None` when no style
/// reaches it or the style names no colour this reader resolves (a textured material
/// without a base). Faces with their own style are coloured by
/// [`Self::triangle_colours`].
pub colour: Option<Rgba>,
/// The graphics from the root to this item, outermost first.
chain: Vec<Graphics>,
palettes: Palettes,
/// An occurrence's entity reference replacing the item's style.
item_override: Option<Graphics>,
/// Per face index, sorted, an entity reference replacing that face's style.
face_overrides: Vec<(u32, Graphics)>,
}
/// The paint of the style `won` names, in its file structure's globals.
fn paint_of(palettes: &Palettes, won: Graphics) -> Option<Paint> {
palettes.get(won.fs)?.get(offset(won.style)).copied().flatten()
}
impl Placement {
/// Per triangle of `mesh` (this placement's tessellation), its colour as
/// [`Self::colour`] describes, with each face's own style taking part in the inheritance
/// [WD 7.2.4, 7.8.6]; `None` when no face of `mesh` carries a style or an override, so
/// every triangle is [`Self::colour`].
#[must_use]
pub fn triangle_colours(&self, mesh: &crate::TriangleMesh) -> Option<Vec<Option<Rgba>>> {
if mesh.triangle_styles.is_empty() && self.face_overrides.is_empty() {
return None;
}
let faces = overrides::triangle_faces(mesh);
let by_face = |k: usize| {
let f = faces.get(k)?;
let i = self.face_overrides.binary_search_by_key(f, |(i, _)| *i).ok()?;
self.face_overrides.get(i).map(|(_, g)| *g)
};
let colour = |won| paint_of(&self.palettes, won).map(|p| p.rgba);
if mesh.triangle_styles.is_empty() {
let whole = self.item_override.unwrap_or_else(|| resolve_style(&self.chain));
return Some((0..mesh.triangles.len()).map(|k| colour(by_face(k).unwrap_or(whole))).collect());
}
let mut chain = self.chain.clone();
chain.push(Graphics::default());
let looks = mesh.triangle_styles.iter().enumerate().map(|(k, s)| {
if let Some(won) = by_face(k).or(self.item_override) {
return colour(won);
}
if let Some(last) = chain.last_mut() {
*last = Graphics { style: s.style, bits: s.behaviour, fs: self.file_structure };
}
colour(resolve_style(&chain))
});
Some(looks.collect())
}
}
/// A representation item that can be drawn, flattened out of any `RI_Set`.
#[derive(Debug, Clone, PartialEq)]
struct Item {
/// `index_local_coordinate_system + 1` of each enclosing set and the item, outermost
/// first; 0 entries dropped.
local: Vec<u32>,
/// `index_tessellation + 1`; never 0 here.
tessellation: u32,
/// The part's graphics, each enclosing set's, then the item's own.
graphics: Vec<Graphics>,
/// The PRC unique id of each enclosing set, then the item's own.
uids: Vec<u32>,
/// A B-rep model's stored `(context, body)` ids [WD 7.6.2].
brep: Option<(u32, u32)>,
}
/// A `ProductOccurrence` (310) as far as placing and naming.
#[derive(Debug, Clone, PartialEq, Default)]
struct Product {
part: u32,
prototype: u32,
prototype_fs: Option<UniqueId>,
external: u32,
external_fs: Option<UniqueId>,
sons: Vec<u32>,
/// Its own graphics hide it (Show clear or Removed set).
hidden: bool,
suppressed: bool,
graphics: Graphics,
location: Option<Matrix>,
name: Option<String>,
/// Its entity references that name a target.
overrides: Vec<Override>,
}
/// One file structure's tree section.
#[derive(Debug, Clone, PartialEq, Default)]
struct Tree {
parts: Vec<Vec<Item>>,
/// Each part definition's name, parallel to `parts`.
part_names: Vec<Option<String>>,
products: Vec<Product>,
}
/// The model file's roots: file structure id and biased occurrence index.
#[derive(Debug, Clone, PartialEq)]
struct ModelFile {
roots: Vec<(UniqueId, u32)>,
}
impl PrcFile {
/// Where each drawable tessellation sits in the model: the product-occurrence tree walked
/// from the model file's roots, each occurrence's location composed onto its father's,
/// and each representation item's local coordinate systems applied last [WD 7.3.10.1,
/// 7.6.3.2], coloured with [`StyleAlpha::ZeroUnset`] and entity references applied by
/// [`EntityOverrides::Subtree`]. Suppressed occurrences and hidden entities are left
/// out; a tessellation several occurrences draw appears once per occurrence. Empty when
/// the file stores no tree ([`Self::stores_tree`]).
///
/// # Errors
/// [`PrcError::Unsupported`] when an item or an entity reference names a reference
/// coordinate system and the globals carry fonts or fill patterns; [`PrcError::Malformed`] for a tree index naming no entity,
/// a cycle of occurrences or prototypes, or a tree past [`MAX_PLACEMENTS`] or the depth
/// ceiling; read errors as [`crate::FileStructure::tessellations`].
pub fn placements(&self) -> Result<Vec<Placement>, PrcError> {
self.placements_with(StyleAlpha::default(), EntityOverrides::default())
}
/// [`Self::placements`], combining style transparency with material alpha by `rule` and
/// applying occurrences' entity references by `scope`.
///
/// # Errors
/// As [`Self::placements`].
pub fn placements_with(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result<Vec<Placement>, PrcError> {
Ok(self.walk(rule, scope)?.0)
}
/// The assembly tree as a model-tree panel lists it: every product occurrence the model
/// file's roots reach, depth first, with its display name and stored visibility. Hidden
/// and suppressed subtrees are listed (with `drawn` false) though [`Self::placements`]
/// leaves them out; each node's `placements` range indexes that list, so attributes each
/// placed mesh to its part. Empty when the file stores no tree.
///
/// # Errors
/// As [`Self::placements`].
pub fn model_tree(&self) -> Result<Vec<ModelNode>, PrcError> {
Ok(self.walk(StyleAlpha::default(), EntityOverrides::default())?.1)
}
/// Whether the file stores a tree: its model file holds an entity. ISO 14739-1 requires
/// one, but a tessellation-only file still draws (unplaced), so its absence reads as an
/// empty tree, not an error.
#[must_use]
pub fn stores_tree(&self) -> bool {
self.model_file.iter().any(|&b| b != 0)
}
fn walk(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result<(Vec<Placement>, Vec<ModelNode>), PrcError> {
self.walk_within(rule, scope, Budget::FULL)
}
/// [`Self::walk`] with `budget` shared by all its readers.
pub(crate) fn walk_within(&self, rule: StyleAlpha, scope: EntityOverrides, budget: Budget) -> Result<(Vec<Placement>, Vec<ModelNode>), PrcError> {
if !self.stores_tree() {
return Ok((Vec::new(), Vec::new()));
}
// One allowance for the whole walk, so many sections cannot each spend a full one.
let mut left = budget;
let mut trees = Vec::with_capacity(self.file_structures.len());
for fs in &self.file_structures {
// The globals open with the schema every reader of this structure uses: read it
// once, charged to the walk, and go on from there for the globals themselves.
let mut globals_reader = BitReader::with_budget(fs.section(SectionKind::Globals), left);
let schema = Schema::read(&mut globals_reader)?;
left = globals_reader.budget();
let mut ctx = Ctx::new(BitReader::with_budget(fs.section(SectionKind::Tree), left), &schema, fs.authoring_version);
let tree = ctx.file_structure_tree()?;
left = ctx.r.budget();
// The globals carry the coordinate systems and the colours; colours alone are not
// worth refusing the placements over.
let mut ctx = Ctx::new(globals_reader.with_remaining(left), &schema, fs.authoring_version);
let globals = ctx.globals();
left = ctx.r.budget();
let globals = match globals {
Ok(g) => g,
Err(e) if places_by_system(&tree) => return Err(e),
Err(_) => Globals::default(),
};
trees.push((fs.id, tree, globals));
}
let mut r = BitReader::with_budget(&self.model_file, left);
let schema = Schema::read(&mut r)?;
let model = Ctx::new(r, &schema, self.header.authoring_version).model_file()?;
let mut walk = Walk::new(trees.iter().map(|(id, t, g)| (*id, t, g)).collect(), rule, scope);
for (id, root) in model.roots {
let fs = trees.iter().position(|t| t.0 == id).ok_or_else(|| malformed("a root names an unknown file structure".into()))?;
if let Some(index) = root.checked_sub(1) {
walk.occurrence(fs, offset(index), &Matrix::IDENTITY, &[], At::ROOT)?;
}
}
Ok((walk.out, walk.nodes))
}
}
/// Whether an item of `tree`, or an entity reference one of its occurrences holds, names a
/// coordinate system: then the globals that store them must have been read.
fn places_by_system(tree: &Tree) -> bool {
tree.parts.iter().flatten().any(|i| !i.local.is_empty()) || tree.products.iter().flat_map(|p| &p.overrides).any(|o| o.local != 0)
}
#[cfg(test)]
mod markup_tests;
#[cfg(test)]
mod tests;
+68
View File
@@ -0,0 +1,68 @@
//! The assembly tree as a list: what a model-tree panel shows.
use std::ops::Range;
/// Where a [`ModelNode`]'s name came from. PRC does not say which name a
/// viewer should show; this reader takes the first present of these, in order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum NameSource {
/// The occurrence's own name.
Occurrence,
/// The name of an occurrence in its prototype chain.
Prototype,
/// The name of the part definition it draws.
Part,
/// No name anywhere: [`ModelNode::name`] is `None`.
Unnamed,
}
/// One product occurrence of a PRC model's assembly tree, as
/// [`PrcFile::model_tree`](crate::PrcFile::model_tree) lists it: depth
/// first, a parent before its children, children in file order.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ModelNode {
/// The name to show, by [`Self::name_from`]; `None` when unnamed.
pub name: Option<String>,
/// Where [`Self::name`] came from.
pub name_from: NameSource,
/// Index of the parent node in the list; `None` for a root.
pub parent: Option<usize>,
/// 0 for a root.
pub depth: usize,
/// Index into [`crate::PrcFile::file_structures`] of the structure
/// holding this occurrence.
pub file_structure: usize,
/// The occurrence's 0-based index in that structure's tree.
pub occurrence: usize,
/// The occurrence's own graphics hide it (Show clear, or Removed)
/// [WD 7.2.4.2]: the visibility the file stores, which a viewer opens on.
pub hidden: bool,
/// The occurrence is suppressed (`product_behavior`) [WD 7.3.10].
pub suppressed: bool,
/// Whether anything of this node is drawn: false when it or an
/// ancestor is hidden or suppressed.
pub drawn: bool,
/// Whether the occurrence (or its prototype) carries a part definition,
/// i.e. geometry of its own rather than only children.
pub has_part: bool,
/// The placements this node and its descendants draw: indices into
/// [`PrcFile::placements`](crate::PrcFile::placements) (or
/// [`placements_with`](crate::PrcFile::placements_with), which yields the same order):
/// the meshes attributed to this occurrence's part and its descendants' parts. Empty when
/// nothing under it is drawn.
pub placements: Range<usize>,
}
impl ModelNode {
/// The label the node is listed under: its name, or `occurrence F:I`
/// (file structure, occurrence) when it has none.
#[must_use]
pub fn label(&self) -> String {
match &self.name {
Some(name) => name.clone(),
None => format!("occurrence {}:{}", self.file_structure, self.occurrence),
}
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Product-occurrence entity references: the colour, visibility and placement an occurrence
//! imposes on an entity it reaches [WD 7.3.10.1, 7.4.4].
use super::{Graphics, Item, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, UniqueId};
/// `PRC_TYPE_TOPO_Face` [WD 6.2].
const TOPO_FACE: u32 = 149;
/// Which entities an occurrence's entity references reach. ISO 14739-1 says a reference
/// overrides the referenced entity's properties, and not where.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum EntityOverrides {
/// Only where the occurrence's own subtree places the target, so one instance of a
/// shared part can be coloured alone.
#[default]
Subtree,
/// Every placement of the target anywhere in the model.
Everywhere,
/// Read past them: every entity keeps its own and inherited graphics.
Ignore,
}
/// One `MISC_EntityReference` held by a product occurrence.
#[derive(Debug, Clone, PartialEq)]
pub(super) struct Override {
/// The reference's graphics; style 0 leaves the target's colour alone.
pub(super) graphics: Graphics,
/// The graphics hide the target (Show clear or Removed set).
pub(super) hidden: bool,
/// `index_local_coordinate_system + 1` replacing the target's; 0 none.
pub(super) local: u32,
pub(super) target: Target,
}
/// What an entity reference names.
#[derive(Debug, Clone, PartialEq)]
pub(super) enum Target {
/// `ReferenceOnPRCBase`: the entity of type `kind` whose PRC unique id is `uid` in
/// structure `fs` (`None`: the occurrence's own).
Entity { kind: u32, fs: Option<UniqueId>, uid: u32 },
/// `ReferenceOnTopology` into the B-rep stored as `(context, body)`: its topological
/// items of type `kind` at `indices` (0-based). With `fs` `None` it matches that B-rep in
/// any structure the subtree reaches: producers write "same structure" for an assembly's
/// reference into a part's own structure.
Topology { kind: u32, fs: Option<UniqueId>, brep: (u32, u32), indices: Vec<u32> },
}
impl Target {
/// The structure the reference names, if it names one.
pub(super) fn fs(&self) -> Option<UniqueId> {
match self {
Target::Entity { fs, .. } | Target::Topology { fs, .. } => *fs,
}
}
}
/// An entity reference in force: the structure of the occurrence holding it, the structure
/// it names (`None`: any), and the reference.
pub(super) type Active<'t> = (usize, Option<usize>, &'t Override);
/// The overrides that reach one representation item.
#[derive(Debug, Default)]
pub(super) struct Reached {
/// Graphics replacing the item's resolved style, in its owner's structure.
pub(super) item: Option<Graphics>,
pub(super) hidden: bool,
/// The owner's structure and its local coordinate system.
pub(super) local: Option<(usize, u32)>,
/// Per face index, sorted, graphics replacing that face's style.
pub(super) faces: Vec<(u32, Graphics)>,
}
impl Reached {
/// Applies `active` innermost first, so an outer occurrence's override wins over an
/// inner one's, as an assembly's appearance beats its sub-assembly's.
pub(super) fn collect(active: &[Active<'_>], fs: usize, item: &Item) -> Self {
let mut r = Reached::default();
// A map, not a list searched per index: one reference may name millions of faces.
let mut faces = std::collections::BTreeMap::new();
for &(owner, named, ov) in active.iter().rev() {
let in_fs = named.is_none_or(|n| n == fs);
let g = Graphics { fs: owner, ..ov.graphics };
match &ov.target {
Target::Entity { kind, uid, .. } => {
if !in_fs || !is_item(*kind) || !item.uids.contains(uid) {
continue;
}
if ov.graphics.style != 0 {
r.item = Some(g);
}
r.hidden = ov.hidden;
if ov.local != 0 {
r.local = Some((owner, ov.local));
}
}
Target::Topology { kind, brep, indices, .. } => {
if !in_fs || *kind != TOPO_FACE || item.brep != Some(*brep) || ov.graphics.style == 0 {
continue;
}
for &f in indices {
faces.insert(f, g);
}
}
}
}
r.faces = faces.into_iter().collect();
r
}
}
/// Representation-item types a `ReferenceOnPRCBase` can colour.
fn is_item(kind: u32) -> bool {
(RI_BREP_MODEL..=RI_COORDINATE_SYSTEM).contains(&kind)
}
/// Per triangle of `mesh`, the index of the `TESS_Face` it was read from (`u32::MAX` for
/// none).
pub(super) fn triangle_faces(mesh: &crate::TriangleMesh) -> Vec<u32> {
let mut out = vec![u32::MAX; mesh.triangles.len()];
for (i, range) in mesh.faces.iter().enumerate() {
let face = u32::try_from(i).unwrap_or(u32::MAX);
for f in out.get_mut(range.clone()).into_iter().flatten() {
*f = face;
}
}
out
}
+452
View File
@@ -0,0 +1,452 @@
//! The tree section's and the model file's entities: part definitions, representation items,
//! product occurrences and their transformations and entity references [WD 7.3, 7.4, 7.6].
use super::{
ABSOLUTE_SCENE_FROM, BASE_WITH_GRAPHICS, CAMERA, CARTESIAN_TRANSFORMATION, ENTITY_REFERENCE, FILE_STRUCTURE, FILE_STRUCTURE_TREE, FILTER,
GENERAL_TRANSFORMATION, Graphics, Item, MAX_DEPTH, MODEL_FILE, Matrix, ModelFile, Override, PART_DEFINITION, PRODUCT_OCCURRENCE, Product,
REFERENCE_ON_PRC_BASE, REFERENCE_ON_TOPOLOGY, REMOVED, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, RI_CURVE, RI_DIRECTION, RI_PLANE, RI_POINT_SET,
RI_POLY_BREP_MODEL, RI_POLY_WIRE, RI_SET, SCENE_DISPLAY_PARAMETERS, SHOW, SUPPRESSED, Target, Tree, UniqueId, Vec3,
};
use crate::PrcError;
use crate::entity::{Ctx, malformed};
impl Ctx<'_, '_> {
pub(super) fn vector3(&mut self) -> Result<Vec3, PrcError> {
Ok(Vec3([self.r.double()?, self.r.double()?, self.r.double()?]))
}
pub(super) fn unique_id(&mut self) -> Result<UniqueId, PrcError> {
let mut id = [0; 4];
for v in &mut id {
*v = self.r.unsigned_integer()?;
}
Ok(UniqueId(id))
}
/// `ContentPRCRefBase` [WD 7.2.3.2; pdf-issues #697]: the base, then the CAD ids and the
/// PRC unique id, which becomes [`Ctx::uid`].
pub(super) fn content_prc_ref_base(&mut self) -> Result<(), PrcError> {
self.content_prc_base()?;
self.r.unsigned_integer()?; // CAD identifier
self.r.unsigned_integer()?; // CAD persistent identifier
self.uid = self.r.unsigned_integer()?;
Ok(())
}
/// `PRCBaseWithGraphics` [WD 7.2.4; pdf-issues #405]: whether the entity's own graphics
/// hide it (Show clear or Removed set), and its graphics. Reused graphics
/// (`same_graphics`) are the current graphics and never hide.
pub(super) fn base_with_graphics(&mut self) -> Result<(bool, Graphics), PrcError> {
self.content_prc_ref_base()?;
let hidden = if self.r.bit()? {
false
} else {
self.r.unsigned_integer()?; // layer + 1
let style = self.r.unsigned_integer()?;
let lo = u16::from(self.r.character()?);
let hi = u16::from(self.r.character()?);
let bits = lo | hi << 8;
self.graphics = Graphics { style, bits, fs: 0 };
bits & SHOW == 0 || bits & REMOVED != 0
};
// prc-rs: schema additions to the base itself follow the graphics.
self.schema.skip_added_fields(BASE_WITH_GRAPHICS, &mut self.r)?;
Ok((hidden, self.graphics))
}
/// `CartesianTransformation` (202) or `GeneralTransformation` (207), type code included
/// [WD 7.4.8, 7.4.9, 7.4.11; pdf-issues #430].
pub(super) fn transformation(&mut self) -> Result<Matrix, PrcError> {
let t = self.r.unsigned_integer()?;
let m = match t {
GENERAL_TRANSFORMATION => {
// Column-major [WD 7.4.9].
let mut m = Matrix::IDENTITY;
for col in 0..4 {
for row in &mut m.0 {
if let Some(c) = row.get_mut(col) {
*c = self.r.double()?;
}
}
}
m
}
CARTESIAN_TRANSFORMATION => self.transformation_3d()?,
t => return Err(malformed(format!("entity type {t} as a transformation"))),
};
self.schema.skip_added_fields(t, &mut self.r)?;
Ok(m)
}
/// The embedded `Transformation3D` [WD 7.4.11.1]: a behaviour byte, then each field its
/// bits name.
pub(super) fn transformation_3d(&mut self) -> Result<Matrix, PrcError> {
let b = self.r.character()?;
let mut m = Matrix::IDENTITY;
let set_col = |m: &mut Matrix, col: usize, v: Vec3| {
for (row, x) in m.0.iter_mut().zip(v.0) {
if let Some(c) = row.get_mut(col) {
*c = x;
}
}
};
if b & 0x01 != 0 {
let t = self.vector3()?;
set_col(&mut m, 3, t);
}
if b & 0x20 != 0 {
for col in 0..3 {
let v = self.vector3()?;
set_col(&mut m, col, v);
}
} else if b & 0x02 != 0 {
let x = self.vector3()?;
let y = self.vector3()?;
// Mirrored (0x04): the third axis is y × x.
let z = if b & 0x04 != 0 { y.cross(x) } else { x.cross(y) };
set_col(&mut m, 0, x);
set_col(&mut m, 1, y);
set_col(&mut m, 2, z);
}
let mut scale = [1.0; 3];
if b & 0x10 != 0 {
scale = self.vector3()?.0;
}
// prc-rs: with both scale bits set, both are read and multiply.
if b & 0x08 != 0 {
let s = self.r.double()?;
scale = scale.map(|v| v * s);
}
for row in m.0.iter_mut().take(3) {
for (c, s) in row.iter_mut().zip(scale) {
*c *= s;
}
}
if b & 0x40 != 0 {
let h = [self.r.double()?, self.r.double()?, self.r.double()?, self.r.double()?];
if let Some(r) = m.0.get_mut(3) {
*r = h;
}
}
Ok(m)
}
/// A polymorphic representation item [WD 7.6]; drawable ones are pushed onto `out` with
/// `local` (the enclosing sets' coordinate systems) prefixed.
// prc-rs: the item's own fields follow its local coordinate system and tessellation.
fn representation_item(&mut self, at: &Item, out: &mut Vec<Item>, depth: usize) -> Result<(), PrcError> {
if depth > MAX_DEPTH {
return Err(malformed("representation sets nest too deep".into()));
}
let t = self.r.unsigned_integer()?;
let (hidden, own) = self.base_with_graphics()?;
let mut item = at.clone();
item.uids.push(self.uid);
item.graphics.push(own);
let cs = self.r.unsigned_integer()?;
let tess = self.r.unsigned_integer()?;
if cs != 0 {
item.local.push(cs);
}
let (drawable, brep) = if t == RI_SET {
let n = self.count(1, "representation set")?;
let mut members = Vec::new();
for _ in 0..n {
self.representation_item(&item, &mut members, depth + 1)?;
}
if !hidden {
out.extend(members);
}
(false, None)
} else {
self.leaf_item_fields(t)?
};
self.schema.skip_added_fields(t, &mut self.r)?;
self.user_data()?;
if drawable && !hidden && tess != 0 {
out.push(Item { tessellation: tess, brep, ..item });
}
Ok(())
}
/// The type-specific fields of a representation item other than `RI_Set` [WD 7.6];
/// whether the item can be drawn, and a B-rep model's `(context, body)`.
fn leaf_item_fields(&mut self, t: u32) -> Result<(bool, Option<(u32, u32)>), PrcError> {
match t {
RI_BREP_MODEL => {
let brep = self.exact_tolerance()?;
self.r.bit()?; // is_closed
return Ok((true, brep));
}
RI_CURVE | RI_PLANE => {
self.exact_tolerance()?;
}
RI_DIRECTION => {
if self.r.bit()? {
self.vector3()?; // origin
}
self.vector3()?;
return Ok((false, None));
}
RI_POINT_SET => {
let n = self.count(6, "point set")?;
for _ in 0..n {
self.vector3()?;
}
}
RI_POLY_BREP_MODEL => {
self.r.bit()?; // is_closed
}
RI_POLY_WIRE => {}
RI_COORDINATE_SYSTEM => {
self.transformation()?;
return Ok((false, None));
}
t => return Err(malformed(format!("entity type {t} as a representation item"))),
}
Ok((true, None))
}
/// A B-rep, curve or plane item's optional exact-geometry `(context, body)` ids
/// [WD 7.6.2].
fn exact_tolerance(&mut self) -> Result<Option<(u32, u32)>, PrcError> {
if self.r.bit()? {
return Ok(Some((self.r.unsigned_integer()?, self.r.unsigned_integer()?)));
}
Ok(None)
}
/// `SceneDisplayParameters` (741) [WD 7.5.19]: read past, lights and clipping planes
/// included.
// prc-rs: the scene's field order, and `is_absolute` from version 8137.
pub(super) fn scene_display_parameters(&mut self) -> Result<(), PrcError> {
self.expect_type(SCENE_DISPLAY_PARAMETERS)?;
self.content_prc_ref_base()?;
self.r.bit()?; // is_active
let n = self.count(1, "scene lights")?;
for _ in 0..n {
self.light()?;
}
if self.r.bit()? {
self.camera()?;
}
if self.r.bit()? {
self.vector3()?; // rotation centre
}
let n = self.count(1, "scene clipping planes")?;
for _ in 0..n {
self.plane()?;
}
self.r.unsigned_integer()?; // background style + 1
self.r.unsigned_integer()?; // default style + 1
let n = self.count(2, "scene default styles")?;
for _ in 0..n {
self.r.unsigned_integer()?; // entity type
self.r.unsigned_integer()?; // style + 1
}
if self.version >= ABSOLUTE_SCENE_FROM {
self.r.bit()?; // is_absolute
}
self.schema.skip_added_fields(SCENE_DISPLAY_PARAMETERS, &mut self.r)
}
/// `Camera` (742) [WD 7.5.20]: read past.
// prc-rs: the camera's field order.
fn camera(&mut self) -> Result<(), PrcError> {
self.expect_type(CAMERA)?;
self.content_prc_ref_base()?;
self.r.bit()?; // is_orthographic
for _ in 0..3 {
self.vector3()?; // position, look, up
}
for _ in 0..6 {
self.r.double()?; // x, y, ratio, near, far, zoom
}
self.schema.skip_added_fields(CAMERA, &mut self.r)
}
/// `MISC_EntityReference` (203) [WD 7.4.4, 7.4.10]; `None` when it names no target.
// prc-rs: the local coordinate system precedes the optional reference data.
fn entity_reference(&mut self) -> Result<Option<Override>, PrcError> {
self.expect_type(ENTITY_REFERENCE)?;
let (hidden, graphics) = self.base_with_graphics()?;
let local = self.r.unsigned_integer()?;
let target = if self.r.bit()? { self.reference_data()? } else { None };
self.schema.skip_added_fields(ENTITY_REFERENCE, &mut self.r)?;
self.user_data()?;
Ok(target.map(|target| Override { graphics, hidden, local, target }))
}
/// `ReferenceData` [WD 7.4.10.2]: a `ReferenceOnPRCBase` (205) or a
/// `ReferenceOnTopology` (206), type code included; `None` when it names no target.
pub(super) fn reference_data(&mut self) -> Result<Option<Target>, PrcError> {
let t = self.r.unsigned_integer()?;
let target = match t {
REFERENCE_ON_PRC_BASE => Some(self.reference_on_prc_base_body()?),
REFERENCE_ON_TOPOLOGY => self.reference_on_topology()?,
t => return Err(malformed(format!("entity type {t} as reference data"))),
};
self.schema.skip_added_fields(t, &mut self.r)?;
Ok(target)
}
/// `ReferenceOnPRCBase` (205) [WD 7.4.6] after its type code.
fn reference_on_prc_base_body(&mut self) -> Result<Target, PrcError> {
let kind = self.r.unsigned_integer()?;
let fs = self.file_identifier()?;
let uid = self.r.unsigned_integer()?;
Ok(Target::Entity { kind, fs, uid })
}
/// `ReferenceOnPRCBase` (205) [WD 7.4.6], type code included: read past.
pub(super) fn reference_on_prc_base(&mut self) -> Result<(), PrcError> {
self.expect_type(REFERENCE_ON_PRC_BASE)?;
self.reference_on_prc_base_body()?;
self.schema.skip_added_fields(REFERENCE_ON_PRC_BASE, &mut self.r)
}
/// `ReferenceOnTopology` (206) after its type code; `None` without a body.
fn reference_on_topology(&mut self) -> Result<Option<Target>, PrcError> {
let kind = self.r.unsigned_integer()?;
if !self.r.bit()? {
return Ok(None);
}
let fs = self.file_identifier()?;
let brep = (self.r.unsigned_integer()?, self.r.unsigned_integer()?);
let indices = self.uints("topology reference indices")?;
Ok(Some(Target::Topology { kind, fs, brep, indices }))
}
/// `ASM_Filter` (320) [WD 7.3.12]: read past.
// prc-rs: the filter's field order.
pub(super) fn filter(&mut self) -> Result<(), PrcError> {
self.expect_type(FILTER)?;
self.content_prc_ref_base()?;
self.r.bit()?; // is_active
self.r.bit()?; // layers inclusive
self.uints("filter layers")?;
self.r.bit()?; // entities inclusive
let n = self.count(1, "filter entities")?;
for _ in 0..n {
self.entity_reference()?;
}
self.schema.skip_added_fields(FILTER, &mut self.r)?;
self.user_data()
}
/// `PartDefinition` (311): its drawable items and its name.
fn part_definition(&mut self) -> Result<(Vec<Item>, Option<String>), PrcError> {
self.expect_type(PART_DEFINITION)?;
let (hidden, own) = self.base_with_graphics()?;
let name = self.name.clone();
self.vector3()?; // bounding box
self.vector3()?;
let n = self.count(1, "representation items")?;
let mut items = Vec::new();
let at = Item { local: Vec::new(), tessellation: 0, graphics: vec![own], uids: Vec::new(), brep: None };
for _ in 0..n {
self.representation_item(&at, &mut items, 0)?;
}
self.markup_data()?;
self.views()?;
self.schema.skip_added_fields(PART_DEFINITION, &mut self.r)?;
self.user_data()?;
if hidden {
items.clear();
}
Ok((items, name))
}
/// `FileIdentifier` [WD 7.3.10.2.2]: `None` for the same structure.
fn file_identifier(&mut self) -> Result<Option<UniqueId>, PrcError> {
if self.r.bit()? { Ok(None) } else { self.unique_id().map(Some) }
}
/// `ProductOccurrence` (310) [WD 7.3.10].
fn product_occurrence(&mut self) -> Result<Product, PrcError> {
self.expect_type(PRODUCT_OCCURRENCE)?;
let (hidden, graphics) = self.base_with_graphics()?;
let mut p = Product { hidden, graphics, name: self.name.clone(), part: self.r.unsigned_integer()?, ..Product::default() };
p.prototype = self.r.unsigned_integer()?;
if p.prototype != 0 {
p.prototype_fs = self.file_identifier()?;
}
p.external = self.r.unsigned_integer()?;
if p.external != 0 {
p.external_fs = self.file_identifier()?;
}
p.sons = self.uints("son occurrences")?;
p.suppressed = self.r.character()? & SUPPRESSED != 0;
// ProductInformation [WD 7.3.10.3]: unit flag, unit, load status, information.
self.r.bit()?;
self.r.double()?;
self.r.character()?;
self.r.integer()?;
if self.r.bit()? {
p.location = Some(self.transformation()?);
}
let n = self.count(1, "entity references")?;
for _ in 0..n {
p.overrides.extend(self.entity_reference()?);
}
self.markup_data()?;
self.views()?;
if self.r.bit()? {
self.filter()?;
}
let n = self.count(1, "display filters")?;
for _ in 0..n {
self.filter()?;
}
let n = self.count(1, "scene display parameters")?;
for _ in 0..n {
self.scene_display_parameters()?;
}
self.schema.skip_added_fields(PRODUCT_OCCURRENCE, &mut self.r)?;
self.user_data()?;
Ok(p)
}
/// `FileStructureTree` (304) [WD 7.3.6], with its `FileStructure` (302) inside.
pub(super) fn file_structure_tree(&mut self) -> Result<Tree, PrcError> {
self.expect_type(FILE_STRUCTURE_TREE)?;
self.content_prc_base()?;
let n = self.count(1, "part definitions")?;
let mut tree = Tree::default();
for _ in 0..n {
let (items, name) = self.part_definition()?;
tree.parts.push(items);
tree.part_names.push(name);
}
let n = self.count(1, "product occurrences")?;
for _ in 0..n {
tree.products.push(self.product_occurrence()?);
}
self.expect_type(FILE_STRUCTURE)?;
self.content_prc_base()?;
self.r.unsigned_integer()?; // next_available_index
self.r.unsigned_integer()?; // product_occurrence_index + 1 (the structure's root)
self.schema.skip_added_fields(FILE_STRUCTURE, &mut self.r)?;
self.schema.skip_added_fields(FILE_STRUCTURE_TREE, &mut self.r)?;
self.user_data()?;
Ok(tree)
}
/// `ModelFile` (301) after its schema [WD 7.3.3; pdf-issues #724, #740]: `units_in_mm` is
/// always present.
pub(super) fn model_file(&mut self) -> Result<ModelFile, PrcError> {
self.expect_type(MODEL_FILE)?;
self.content_prc_base()?;
self.r.bit()?; // units_from_cad_file
self.r.double()?; // units_in_mm
let n = self.count(6, "root occurrences")?;
let mut roots = Vec::with_capacity(n);
for _ in 0..n {
let id = self.unique_id()?;
let index = self.r.unsigned_integer()?;
self.r.bit()?; // is_active
roots.push((id, index));
}
Ok(ModelFile { roots })
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Graphics, styles, materials and the globals that hold them [WD 7.2.4, 7.3.5, 7.5].
use super::{
FATHER_HERIT_COLOR, FILE_STRUCTURE_GLOBALS, LINE_PATTERN, MATERIAL, Matrix, PICTURE, RI_COORDINATE_SYSTEM, SON_HERIT_COLOR, STYLE,
TEXTURE_ALPHA_TEST, TEXTURE_APPLICATION, TEXTURE_DEFINITION, TEXTURE_FUNCTION_BLEND, TEXTURE_MAPPING_OPERATOR, TEXTURE_TRANSFORMATION, Vec3,
};
use crate::PrcError;
use crate::container::offset;
use crate::entity::{Ctx, malformed};
/// One entity's `GraphicsContent` [WD 7.2.4]: `style` is `line_style_index + 1` (0 none),
/// `bits` the behaviour bits. `fs` is the file structure whose globals `style` indexes: the
/// one whose tree holds the entity. The walk sets it; a reader leaves it 0.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct Graphics {
pub(crate) style: u32,
pub(crate) bits: u16,
pub(crate) fs: usize,
}
/// A straight (not premultiplied) colour, each channel 0-1; `a` is the opacity.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[non_exhaustive]
pub struct Rgba {
/// Red.
pub r: f64,
/// Green.
pub g: f64,
/// Blue.
pub b: f64,
/// Opacity: 0 transparent, 1 opaque.
pub a: f64,
}
/// How a style's `transparency` combines with its material's diffuse alpha. ISO 14739-1
/// defines both and not their combination, and gives a diffuse alpha of 0.0 no "unset"
/// meaning.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum StyleAlpha {
/// As [`Self::StyleWins`], except that a material diffuse alpha of exactly 0.0 under a
/// style with no transparency reads as unset, so opaque. Producers write 0.0 on the
/// materials of models meant to be seen; read literally, every part is invisible.
#[default]
ZeroUnset,
/// The style's transparency, when it has one, is the opacity; the material alpha, read
/// literally (0.0 invisible), applies only to a style without one.
StyleWins,
/// The opacity is material alpha × style transparency.
Multiply,
}
/// A style's resolved colour, and whether its opacity comes from reading a zero material
/// alpha as unset.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Paint {
pub(crate) rgba: Rgba,
pub(crate) alpha_unset: bool,
}
/// A `Style` (701) as far as colour: `index` is `colour_or_material + 1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Style {
pub(crate) is_material: bool,
pub(crate) index: u32,
pub(crate) transparency: Option<u8>,
}
/// A `materials` entry as far as colour.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Material {
/// `Material` (702): `diffuse + 1` (a colour index) and the diffuse alpha.
Plain { diffuse: u32, alpha: f64 },
/// `TextureApplication` (711) [WD 7.5.6]: `material_generic_index + 1`, the material
/// under the texture.
Textured { base: u32 },
}
/// What `FileStructureGlobals` contributes to placing and colouring.
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct Globals {
/// Entry `i` places an item with biased local coordinate system `i + 1`.
pub(crate) systems: Vec<Matrix>,
/// RGB, `a` 1.
pub(crate) colours: Vec<Rgba>,
pub(crate) materials: Vec<Material>,
pub(crate) styles: Vec<Style>,
}
/// The entry at biased index `biased` (`index + 1`); `None` for 0 or past the end.
pub(crate) fn biased<T>(list: &[T], biased: u32) -> Option<&T> {
list.get(offset(biased.checked_sub(1)?))
}
impl Globals {
/// The colour a style or material names by `biased` (`index + 1`). Stored indices count
/// the colour array's doubles, so `colours[i]` is stored as `3i + 1`; an index that is
/// not of that form names nothing.
fn colour(&self, biased: u32) -> Option<Rgba> {
let i = biased.checked_sub(1)?;
// pdf-issues #816: a colour index counts doubles, three per colour.
if i % 3 != 0 {
return None;
}
self.colours.get(offset(i / 3)).copied()
}
/// The colour of style `biased` (`line_style_index + 1`) under `rule`.
pub(crate) fn style_paint(&self, biased_style: u32, rule: StyleAlpha) -> Option<Paint> {
let style = biased(&self.styles, biased_style)?;
let (rgb, mut alpha) = if style.is_material {
let mut m = biased(&self.materials, style.index)?;
if let Material::Textured { base } = *m {
m = biased(&self.materials, base)?;
}
match *m {
Material::Plain { diffuse, alpha } => (self.colour(diffuse)?, alpha),
Material::Textured { .. } => return None,
}
} else {
(self.colour(style.index)?, 1.0)
};
let mut alpha_unset = false;
match style.transparency {
Some(t) => {
let t = f64::from(t) / 255.0;
alpha = match rule {
StyleAlpha::ZeroUnset | StyleAlpha::StyleWins => t,
StyleAlpha::Multiply => alpha * t,
};
}
None if rule == StyleAlpha::ZeroUnset && style.is_material && alpha == 0.0 => {
alpha = 1.0;
alpha_unset = true;
}
None => {}
}
// A NaN alpha (a stored double may be one) reads as opaque.
let a = if alpha.is_nan() { 1.0 } else { alpha.clamp(0.0, 1.0) };
Some(Paint { rgba: Rgba { a, ..rgb }, alpha_unset })
}
}
/// The style a chain of graphics resolves to, outermost first [WD 7.2.4.2]: a son's own
/// style is used unless an ancestor set `FatherHeritColor` (the oldest such wins), and a son
/// setting `SonHeritColor` overrides that. Entities with no style inherit. Returns the
/// winning entity's graphics, style 0 when none.
pub(crate) fn resolve_style(chain: &[Graphics]) -> Graphics {
let (mut won, mut forced) = (Graphics::default(), false);
for g in chain {
if g.style == 0 {
continue;
}
if !forced || g.bits & SON_HERIT_COLOR != 0 {
won = *g;
forced = g.bits & FATHER_HERIT_COLOR != 0;
}
}
won
}
impl Ctx<'_, '_> {
/// `FileStructureGlobals` (303) [WD 7.3.5]: colours, materials, styles and the reference
/// coordinate systems. Pictures, textures and line patterns are read past; fonts and fill
/// patterns are [`PrcError::Unsupported`].
// prc-rs: the globals' field order.
pub(crate) fn globals(&mut self) -> Result<Globals, PrcError> {
let mut g = Globals::default();
self.expect_type(FILE_STRUCTURE_GLOBALS)?;
self.content_prc_base()?;
let n = self.count(4, "referenced file structures")?;
for _ in 0..n {
self.unique_id()?;
}
self.r.double()?; // tessellation chord-height ratio
self.r.double()?; // tessellation angle
self.r.string()?; // default font family
if self.r.unsigned_integer()? != 0 {
return Err(PrcError::Unsupported("PRC global fonts"));
}
let n = self.count(6, "colours")?;
g.colours = (0..n).map(|_| self.vector3().map(|Vec3([r, g, b])| Rgba { r, g, b, a: 1.0 })).collect::<Result<_, _>>()?;
let n = self.count(1, "pictures")?;
for _ in 0..n {
self.picture()?;
}
let n = self.count(1, "texture definitions")?;
for _ in 0..n {
self.texture_definition()?;
}
let n = self.count(1, "materials")?;
g.materials = (0..n).map(|_| self.material()).collect::<Result<_, _>>()?;
let n = self.count(1, "line patterns")?;
for _ in 0..n {
self.line_pattern()?;
}
let n = self.count(1, "styles")?;
g.styles = (0..n).map(|_| self.style()).collect::<Result<_, _>>()?;
if self.r.unsigned_integer()? != 0 {
return Err(PrcError::Unsupported("PRC fill patterns"));
}
let n = self.count(1, "reference coordinate systems")?;
for _ in 0..n {
self.expect_type(RI_COORDINATE_SYSTEM)?;
self.base_with_graphics()?;
self.r.unsigned_integer()?; // local coordinate system + 1
self.r.unsigned_integer()?; // tessellation + 1
g.systems.push(self.transformation()?);
self.schema.skip_added_fields(RI_COORDINATE_SYSTEM, &mut self.r)?;
self.user_data()?;
}
self.schema.skip_added_fields(FILE_STRUCTURE_GLOBALS, &mut self.r)?;
self.user_data()?;
Ok(g)
}
/// `LinePattern` (721) [WD 7.5.2]: read past.
fn line_pattern(&mut self) -> Result<(), PrcError> {
self.expect_type(LINE_PATTERN)?;
self.content_prc_ref_base()?;
self.doubles("line pattern lengths")?;
self.r.double()?; // phase
self.r.bit()?; // is_real_length
self.schema.skip_added_fields(LINE_PATTERN, &mut self.r)
}
/// One `materials` entry, type-tagged: `Material` (702) or `TextureApplication` (711)
/// [WD 7.5.4, 7.5.6].
// prc-rs: a material entry is type-tagged, then a ref base, then its fields.
fn material(&mut self) -> Result<Material, PrcError> {
let t = self.r.unsigned_integer()?;
self.content_prc_ref_base()?;
let m = match t {
MATERIAL => {
self.r.unsigned_integer()?; // ambient + 1
let diffuse = self.r.unsigned_integer()?;
self.r.unsigned_integer()?; // emissive + 1
self.r.unsigned_integer()?; // specular + 1
self.r.double()?; // shininess
self.r.double()?; // ambient alpha
let alpha = self.r.double()?;
self.r.double()?; // emissive alpha
self.r.double()?; // specular alpha
Material::Plain { diffuse, alpha }
}
TEXTURE_APPLICATION => {
let base = self.r.unsigned_integer()?;
self.r.unsigned_integer()?; // texture definition + 1
self.r.unsigned_integer()?; // next texture + 1
self.r.unsigned_integer()?; // UV coordinates set + 1
Material::Textured { base }
}
t => return Err(malformed(format!("entity type {t} as a material"))),
};
self.schema.skip_added_fields(t, &mut self.r)?;
Ok(m)
}
/// `Style` (701) [WD 7.5.3].
// prc-rs: the transparency and the three rendering parameters each follow a presence bit.
fn style(&mut self) -> Result<Style, PrcError> {
self.expect_type(STYLE)?;
self.content_prc_ref_base()?;
self.r.double()?; // line width
self.r.bit()?; // is_vpicture
self.r.unsigned_integer()?; // line pattern + 1
let is_material = self.r.bit()?;
let index = self.r.unsigned_integer()?;
let transparency = if self.r.bit()? { Some(self.r.character()?) } else { None };
for _ in 0..3 {
if self.r.bit()? {
self.r.character()?;
}
}
self.schema.skip_added_fields(STYLE, &mut self.r)?;
Ok(Style { is_material, index, transparency })
}
/// `Picture` (703) [WD 7.5.5]: read past.
fn picture(&mut self) -> Result<(), PrcError> {
self.expect_type(PICTURE)?;
self.content_prc_base()?;
for _ in 0..4 {
self.r.unsigned_integer()?; // format, file + 1, width, height
}
self.schema.skip_added_fields(PICTURE, &mut self.r)
}
/// `TextureDefinition` (712) [WD 7.5.7; pdf-issues #485]: read past.
fn texture_definition(&mut self) -> Result<(), PrcError> {
self.expect_type(TEXTURE_DEFINITION)?;
self.content_prc_ref_base()?;
self.r.unsigned_integer()?; // picture + 1
let dimension = self.r.character()?;
if self.r.integer()? == TEXTURE_MAPPING_OPERATOR {
self.r.integer()?; // mapping operator
if self.r.bit()? {
self.transformation()?;
}
}
self.r.unsigned_integer()?; // channels
self.doubles("texture intensities")?;
let n = self.count(8, "texture components")?;
for _ in 0..n {
self.r.character()?;
}
if self.r.integer()? == TEXTURE_FUNCTION_BLEND {
for _ in 0..4 {
self.r.double()?; // blend colour
}
}
for _ in 0..2 {
// RGB, then alpha: a source blend, and a destination one when set.
if self.r.integer()? != 0 {
self.r.integer()?;
}
}
if self.r.character()? & TEXTURE_ALPHA_TEST != 0 {
self.r.integer()?; // alpha test function
self.r.double()?; // reference
}
for _ in 0..dimension.clamp(1, 3) {
self.r.integer()?; // wrapping mode
}
if self.r.bit()? {
self.texture_transformation()?;
}
self.schema.skip_added_fields(TEXTURE_DEFINITION, &mut self.r)
}
/// `TextureTransformation` (713) [WD 7.5.8, 7.4.11.1; pdf-issues #819]: read past.
fn texture_transformation(&mut self) -> Result<(), PrcError> {
self.expect_type(TEXTURE_TRANSFORMATION)?;
self.r.bit()?; // invert u
self.r.bit()?; // invert v
self.r.bit()?; // is 2D
let b = self.r.character()?;
// Translation; non-orthogonal axes, else one rotation vector; non-uniform, else
// uniform scale; homogeneous row.
let doubles = [(0x01, 2), (0x20, 4), (0x10, 2), (0x40, 3)].iter().filter(|(bit, _)| b & bit != 0).map(|(_, n)| n).sum::<usize>()
+ if b & 0x20 == 0 && b & 0x02 != 0 { 2 } else { 0 }
+ usize::from(b & 0x10 == 0 && b & 0x08 != 0);
for _ in 0..doubles {
self.r.double()?;
}
self.schema.skip_added_fields(TEXTURE_TRANSFORMATION, &mut self.r)
}
}
+714
View File
@@ -0,0 +1,714 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::style::{Material, Style};
use super::{
EntityOverrides, FATHER_HERIT_COLOR, FILE_STRUCTURE_TREE, Globals, Graphics, MAX_PLACEMENTS, NameSource, SHOW, SON_HERIT_COLOR, StyleAlpha,
resolve_style,
};
use crate::bits::{BitReader, Budget};
use crate::entity::Ctx;
use crate::tess::tests::two_face_square;
use crate::testw::W;
use crate::{PrcError, PrcFile, Rgba, Schema, Tessellation, TriangleMesh, UniqueId};
const RED: Rgba = Rgba { r: 1., g: 0., b: 0., a: 1. };
const GREEN: Rgba = Rgba { r: 0., g: 1., b: 0., a: 1. };
const BLUE: Rgba = Rgba { r: 0., g: 0., b: 1., a: 1. };
/// Visible graphics with no style.
pub(super) const PLAIN: (u32, u16) = (0, SHOW);
/// The biased style indices of [`palette`]: red, green, a blue material.
pub(super) const S_RED: u32 = 1;
const S_GREEN: u32 = 2;
const S_BLUE: u32 = 3;
const RI_POLY_BREP: u32 = 237;
fn fs_id(i: usize) -> UniqueId {
UniqueId([0x100 + u32::try_from(i).unwrap(), 1, 2, 3])
}
fn len(n: usize) -> u32 {
u32::try_from(n).unwrap()
}
/// `ContentPRCBase` with no attributes and its own name.
pub(super) fn base(w: &mut W, name: Option<&str>) {
w.uint(0).bit(false).string(name);
}
pub(super) fn ref_base(w: &mut W, name: Option<&str>, uid: u32) {
base(w, name);
w.uint(0).uint(0).uint(uid);
}
/// `PRCBaseWithGraphics` with its own `(style, behaviour)`.
pub(super) fn graphics(w: &mut W, name: Option<&str>, uid: u32, (style, bits): (u32, u16)) {
ref_base(w, name, uid);
w.bit(false).uint(0).uint(style).put(u64::from(bits & 0xff), 8).put(u64::from(bits >> 8), 8);
}
fn file_id(w: &mut W, fs: Option<usize>) {
match fs {
None => {
w.bit(true);
}
Some(i) => {
w.bit(false);
for v in fs_id(i).0 {
w.uint(v);
}
}
}
}
/// An `RI_PolyBrepModel` drawing biased tessellation `tess` under local system `cs`.
pub(super) fn poly_item(w: &mut W, uid: u32, cs: u32, tess: u32, g: (u32, u16)) {
w.uint(RI_POLY_BREP);
graphics(w, None, uid, g);
w.uint(cs).uint(tess).bit(false).uint(0);
}
/// An `RI_BrepModel` drawing tessellation 1, its exact body stored as `brep`.
fn brep_item(w: &mut W, uid: u32, g: (u32, u16), brep: (u32, u32)) {
w.uint(232);
graphics(w, None, uid, g);
w.uint(0).uint(1).bit(true).uint(brep.0).uint(brep.1).bit(false).uint(0);
}
/// A `PartDefinition` holding the `n` items `items` writes, and no markups or views.
fn part(name: Option<&str>, g: (u32, u16), n: usize, items: impl FnOnce(&mut W)) -> W {
let mut none = W::default();
for _ in 0..5 {
none.uint(0);
}
part_with(name, g, n, items, &none)
}
/// [`part`] with `markups` (its markup data and views) written after the items.
pub(super) fn part_with(name: Option<&str>, g: (u32, u16), n: usize, items: impl FnOnce(&mut W), markups: &W) -> W {
let mut w = W::default();
w.uint(311);
graphics(&mut w, name, 0, g);
for _ in 0..6 {
w.double(0.);
}
w.uint(len(n));
items(&mut w);
w.append(markups).uint(0); // user data
w
}
/// One poly-B-rep item (uid 7) drawing tessellation 1 in style `style`.
pub(super) fn plate(style: u32) -> W {
part(Some("Plate"), PLAIN, 1, |w| poly_item(w, 7, 0, 1, (style, SHOW)))
}
#[derive(Default)]
pub(super) struct Occ<'a> {
pub(super) name: Option<&'a str>,
pub(super) g: Option<(u32, u16)>,
pub(super) part: u32,
pub(super) prototype: u32,
pub(super) prototype_fs: Option<usize>,
pub(super) sons: Vec<u32>,
pub(super) suppressed: bool,
pub(super) location: Option<W>,
pub(super) refs: Vec<W>,
/// Its markup data and views; none when `None`.
pub(super) markups: Option<W>,
}
fn occ(w: &mut W, o: &Occ<'_>) {
w.uint(310);
graphics(w, o.name, 0, o.g.unwrap_or(PLAIN));
w.uint(o.part).uint(o.prototype);
if o.prototype != 0 {
file_id(w, o.prototype_fs);
}
w.uint(0).uint(len(o.sons.len()));
for &s in &o.sons {
w.uint(s);
}
w.put(u64::from(o.suppressed), 8);
w.bit(false).double(1.).put(0, 8).int(0);
match &o.location {
Some(t) => w.bit(true).append(t),
None => w.bit(false),
};
w.uint(len(o.refs.len()));
for r in &o.refs {
w.append(r);
}
match &o.markups {
Some(m) => {
w.append(m);
}
None => {
for _ in 0..5 {
w.uint(0);
}
}
}
w.bit(false).uint(0).uint(0).uint(0);
}
pub(super) fn tree(parts: &[W], occs: &[Occ<'_>]) -> W {
let mut w = W::default();
w.uint(304);
base(&mut w, None);
w.uint(len(parts.len()));
for p in parts {
w.append(p);
}
w.uint(len(occs.len()));
for o in occs {
occ(&mut w, o);
}
w.uint(302);
base(&mut w, None);
w.uint(0).uint(0).uint(0);
w
}
fn style(index: u32, is_material: bool, transparency: Option<u8>) -> W {
let mut w = W::default();
w.uint(701);
ref_base(&mut w, None, 0);
w.double(0.).bit(false).uint(0).bit(is_material).uint(index);
match transparency {
Some(t) => w.bit(true).put(u64::from(t), 8),
None => w.bit(false),
};
w.bit(false).bit(false).bit(false);
w
}
fn material(diffuse: u32, alpha: f64) -> W {
let mut w = W::default();
w.uint(702);
ref_base(&mut w, None, 0);
w.uint(0).uint(diffuse).uint(0).uint(0);
w.double(0.).double(1.).double(alpha).double(1.).double(1.);
w
}
/// A globals section (schema included) with `colours`, `materials`, `styles` and reference
/// coordinate systems `systems` (transformations).
fn globals(colours: &[Rgba], materials: &[W], styles: &[W], systems: &[W]) -> W {
let mut schema = W::default();
schema.uint(0);
globals_after(schema, colours, materials, styles, systems)
}
/// [`globals`] after the already-written `schema`.
fn globals_after(mut w: W, colours: &[Rgba], materials: &[W], styles: &[W], systems: &[W]) -> W {
w.uint(303);
base(&mut w, None);
w.uint(0).double(0.).double(0.).string(None).uint(0);
w.uint(len(colours.len()));
for c in colours {
w.double(c.r).double(c.g).double(c.b);
}
w.uint(0).uint(0).uint(len(materials.len()));
for m in materials {
w.append(m);
}
w.uint(0).uint(len(styles.len()));
for s in styles {
w.append(s);
}
w.uint(0).uint(len(systems.len()));
for t in systems {
w.uint(240);
graphics(&mut w, None, 0, PLAIN);
w.uint(0).uint(0).append(t).uint(0);
}
w.uint(0);
w
}
/// Red, green and blue colours, and styles [`S_RED`], [`S_GREEN`] and [`S_BLUE`] (a
/// material with diffuse alpha 0).
fn palette() -> W {
globals(&[RED, GREEN, BLUE], &[material(7, 0.)], &[style(1, false, None), style(4, false, None), style(1, true, None)], &[])
}
fn translate(t: [f64; 3]) -> W {
let mut w = W::default();
w.uint(202).put(0x01, 8).double(t[0]).double(t[1]).double(t[2]);
w
}
/// A translation by `t` after a uniform scale by `s`.
fn translate_scale(t: [f64; 3], s: f64) -> W {
let mut w = W::default();
w.uint(202).put(0x01 | 0x08, 8).double(t[0]).double(t[1]).double(t[2]).double(s);
w
}
/// An entity reference giving the item with PRC unique id `uid` the graphics `g`.
fn ref_entity(uid: u32, g: (u32, u16)) -> W {
ref_entity_at(uid, g, 0)
}
/// [`ref_entity`], also placing its target by biased coordinate system `local`.
fn ref_entity_at(uid: u32, g: (u32, u16), local: u32) -> W {
let mut w = W::default();
w.uint(203);
graphics(&mut w, None, 0, g);
w.uint(local).bit(true).uint(205).uint(RI_POLY_BREP).bit(true).uint(uid).uint(0);
w
}
/// An entity reference giving faces `faces` of B-rep `brep` the graphics `g`.
fn ref_faces(brep: (u32, u32), faces: &[u32], g: (u32, u16)) -> W {
let mut w = W::default();
w.uint(203);
graphics(&mut w, None, 0, g);
w.uint(0).bit(true).uint(206).uint(149).bit(true).bit(true).uint(brep.0).uint(brep.1).uint(len(faces.len()));
for &f in faces {
w.uint(f);
}
w.uint(0);
w
}
/// A model file (schema included) whose roots are `(file structure, biased occurrence)`.
fn model(roots: &[(usize, u32)]) -> W {
let mut w = W::default();
w.uint(0).uint(301);
base(&mut w, None);
w.bit(false).double(1.).uint(len(roots.len()));
for &(fs, index) in roots {
for v in fs_id(fs).0 {
w.uint(v);
}
w.uint(index).bit(true);
}
w
}
/// One file structure's globals, tree and tessellation sections.
struct Fs {
globals: W,
tree: W,
tess: W,
}
fn zlib(data: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn u32s(out: &mut Vec<u8>, vs: &[u32]) {
vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes()));
}
/// A PRC stream of `structures` and `model`.
fn prc(structures: &[Fs], model: &W) -> Vec<u8> {
let header_len = 59 + 48 * structures.len();
let at = |body: &Vec<u8>| len(header_len + body.len());
let mut body = Vec::new();
let mut descriptions = Vec::new();
for (i, fs) in structures.iter().enumerate() {
let mut offs = vec![at(&body)];
body.extend(b"PRC");
u32s(&mut body, &[8137, 8137]);
u32s(&mut body, &fs_id(i).0);
u32s(&mut body, &[0, 0, 0, 0, 0]);
for s in [fs.globals.bytes(), fs.tree.bytes(), fs.tess.bytes(), Vec::new(), Vec::new()] {
offs.push(at(&body));
body.extend(zlib(&s));
}
descriptions.push((fs_id(i), offs));
}
let mf_start = at(&body);
body.extend(zlib(&model.bytes()));
let mf_end = at(&body);
let mut out = b"PRC".to_vec();
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, len(structures.len())]);
for (id, offs) in descriptions {
u32s(&mut out, &id.0);
u32s(&mut out, &[0, 6]);
u32s(&mut out, &offs);
}
u32s(&mut out, &[mf_start, mf_end, 0]);
out.extend(body);
assert_eq!(out.len(), usize::try_from(mf_end).unwrap());
out
}
/// One file structure with [`palette`], `parts` and `occs`, whose root is occurrence 0.
pub(super) fn single(parts: &[W], occs: &[Occ<'_>]) -> PrcFile {
PrcFile::parse(&prc(&[Fs { globals: palette(), tree: tree(parts, occs), tess: two_face_square() }], &model(&[(0, 1)]))).unwrap()
}
fn mesh(f: &PrcFile, fs: usize) -> TriangleMesh {
match f.file_structures[fs].tessellations().unwrap().into_iter().next() {
Some(Tessellation::Mesh(m)) => m,
other => panic!("not a mesh: {other:?}"),
}
}
fn colours(f: &PrcFile, scope: EntityOverrides) -> Vec<Option<Rgba>> {
f.placements_with(StyleAlpha::default(), scope).unwrap().iter().map(|p| p.colour).collect()
}
fn malformed(r: Result<impl std::fmt::Debug, PrcError>) -> String {
match r {
Err(PrcError::Malformed(m)) => m,
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn a_parsed_assembly_places_names_and_colours_each_occurrence() {
let occs = [
Occ { name: Some("Asm"), sons: vec![1, 2], location: Some(translate([0., 0., 100.])), ..Occ::default() },
Occ { part: 1, location: Some(translate([10., 0., 0.])), ..Occ::default() },
Occ { name: Some("B"), part: 1, location: Some(translate([0., 5., 0.])), ..Occ::default() },
];
let f = single(&[plate(S_RED)], &occs);
assert!(f.stores_tree());
let p = f.placements().unwrap();
assert_eq!(p.len(), 2);
let m = mesh(&f, 0);
let moved: Vec<_> = p.iter().map(|p| (p.file_structure, p.tessellation, m.transformed(&p.matrix).positions[2])).collect();
assert_eq!(moved, [(0, 0, [11., 1., 100.]), (0, 0, [1., 6., 100.])]);
assert_eq!(p.iter().map(|p| p.colour).collect::<Vec<_>>(), [Some(RED); 2]);
// A material with diffuse alpha 0 and no style transparency reads as opaque.
assert_eq!(colours(&single(&[plate(S_BLUE)], &occs), EntityOverrides::default()), [Some(BLUE); 2]);
let nodes = f.model_tree().unwrap();
let summary: Vec<_> = nodes.iter().map(|n| (n.name.as_deref(), n.name_from, n.parent, n.depth, n.placements.clone())).collect();
assert_eq!(
summary,
[
(Some("Asm"), NameSource::Occurrence, None, 0, 0..2),
(Some("Plate"), NameSource::Part, Some(0), 1, 0..1),
(Some("B"), NameSource::Occurrence, Some(0), 1, 1..2),
]
);
}
#[test]
fn a_file_without_a_model_file_entity_has_no_tree() {
let fs = Fs { globals: palette(), tree: tree(&[], &[]), tess: two_face_square() };
let bytes = prc(&[fs], &W::default());
let f = PrcFile::parse(&bytes).unwrap();
assert!(!f.stores_tree());
assert!(f.placements().unwrap().is_empty());
}
#[test]
fn an_occurrence_that_contains_itself_is_an_error() {
let occs = [Occ { sons: vec![1], ..Occ::default() }, Occ { part: 1, sons: vec![0, 0], ..Occ::default() }];
assert!(malformed(single(&[plate(S_RED)], &occs).placements()).contains("contains itself"));
}
#[test]
fn a_prototype_cycle_is_an_error() {
let occs = [Occ { prototype: 2, ..Occ::default() }, Occ { prototype: 1, ..Occ::default() }];
assert!(malformed(single(&[], &occs).model_tree()).contains("own prototype"));
}
/// One element allowance covers the whole walk: an allowance that fits one file structure does
/// not fit two, and running short of it is reported as such.
#[test]
fn one_element_allowance_covers_the_whole_walk() {
// A part of 64 items, so each file structure's tree section, not its globals or the model
// file, decodes most of the elements.
let wide = || part(None, PLAIN, 64, |w| (0..64).for_each(|_| poly_item(w, 0, 0, 1, PLAIN)));
let fs = || Fs { globals: palette(), tree: tree(&[wide()], &[Occ { part: 1, ..Occ::default() }]), tess: two_face_square() };
let one = PrcFile::parse(&prc(&[fs()], &model(&[(0, 1)]))).unwrap();
let two = PrcFile::parse(&prc(&[fs(), fs()], &model(&[(0, 1), (1, 1)]))).unwrap();
let walk = |f: &PrcFile, n| f.walk_within(StyleAlpha::default(), EntityOverrides::default(), Budget { elements: n, ..Budget::FULL });
let short = |f: &PrcFile, n| matches!(walk(f, n), Err(PrcError::TooManyElements { .. }));
// One structure decodes about 70 elements, two about 140.
assert!(short(&one, 32));
assert_eq!(walk(&one, 100).map(|w| w.0.len()), Ok(64));
assert!(short(&two, 100), "each structure fits in 100, both do not");
assert_eq!(walk(&two, 200).map(|w| w.0.len()), Ok(128));
}
/// One schema-step allowance covers the whole walk: each structure's tree runs a 2,000-step
/// program, which a 3,000-step allowance fits once but not twice.
#[test]
fn one_step_allowance_covers_the_whole_walk() {
let fs = || {
let mut schema = W::default();
// For (1000) { empty block } on the file structure tree.
schema.uint(1).uint(FILE_STRUCTURE_TREE).uint(5);
for t in [15, 26, 1000, 19, 21] {
schema.uint(t);
}
let globals = globals_after(schema, &[RED], &[], &[], &[]);
Fs { globals, tree: tree(&[plate(S_RED)], &[Occ { part: 1, ..Occ::default() }]), tess: two_face_square() }
};
let one = PrcFile::parse(&prc(&[fs()], &model(&[(0, 1)]))).unwrap();
let two = PrcFile::parse(&prc(&[fs(), fs()], &model(&[(0, 1), (1, 1)]))).unwrap();
let walk = |f: &PrcFile| f.walk_within(StyleAlpha::default(), EntityOverrides::default(), Budget { steps: 3000, ..Budget::FULL });
assert_eq!(walk(&one).map(|w| w.0.len()), Ok(1));
assert!(matches!(walk(&two), Err(PrcError::Malformed(m)) if m.contains("step ceiling")));
assert_eq!(two.placements().map(|p| p.len()), Ok(2));
}
/// Each structure's schema is read once, charged to the walk's allowance: a schema too large
/// for what is left is an error, not decoded outside the allowance and then skipped.
#[test]
fn the_globals_schema_is_charged_to_the_walk() {
let mut schema = W::default();
// 200 empty entries cost 200 elements.
schema.uint(200);
for t in 0..200 {
schema.uint(50_000 + t).uint(0);
}
let fs = Fs {
globals: globals_after(schema, &[RED], &[], &[], &[]),
tree: tree(&[plate(S_RED)], &[Occ { part: 1, ..Occ::default() }]),
tess: two_face_square(),
};
let f = PrcFile::parse(&prc(&[fs], &model(&[(0, 1)]))).unwrap();
let walk = |n| f.walk_within(StyleAlpha::default(), EntityOverrides::default(), Budget { elements: n, ..Budget::FULL });
assert!(matches!(walk(100), Err(PrcError::TooManyElements { .. })));
assert_eq!(walk(1000).map(|w| w.0.len()), Ok(1));
}
/// Globals that cannot be read are reported, not replaced by empty ones, when an entity
/// reference names one of their coordinate systems.
#[test]
fn unreadable_globals_are_an_error_when_a_reference_names_a_coordinate_system() {
let file = |local| {
let mut fonts = W::default();
fonts.uint(0).uint(303);
base(&mut fonts, None);
fonts.uint(0).double(0.).double(0.).string(None).uint(1);
let occs = [Occ { part: 1, refs: vec![ref_entity_at(7, PLAIN, local)], ..Occ::default() }];
let fs = Fs { globals: fonts, tree: tree(&[plate(S_RED)], &occs), tess: two_face_square() };
PrcFile::parse(&prc(&[fs], &model(&[(0, 1)]))).unwrap()
};
assert_eq!(file(0).placements().map(|p| p.len()), Ok(1));
assert!(matches!(file(1).placements(), Err(PrcError::Unsupported("PRC global fonts"))));
}
#[test]
fn an_exponential_walk_is_cut_off() {
// Occurrence k has occurrence k + 1 twice as sons: 2^21 visits.
let occs: Vec<_> = (0..21).map(|k| Occ { sons: if k < 20 { vec![k + 1, k + 1] } else { Vec::new() }, ..Occ::default() }).collect();
assert!(malformed(single(&[], &occs).model_tree()).contains("too large"));
}
#[test]
fn placements_past_the_ceiling_are_an_error() {
// 1,025 placements of a part with 1,024 items.
let items = 1024;
let wide = part(None, PLAIN, items, |w| (0..items).for_each(|_| poly_item(w, 0, 0, 1, PLAIN)));
let occs = [Occ { sons: vec![1; 1025], ..Occ::default() }, Occ { part: 1, ..Occ::default() }];
assert!(1025 * items > MAX_PLACEMENTS);
assert!(malformed(single(&[wide], &occs).placements()).contains("placements"));
}
#[test]
fn hidden_and_suppressed_occurrences_are_listed_but_not_placed() {
let occs = [
Occ { sons: vec![1, 2, 3], ..Occ::default() },
Occ { part: 1, ..Occ::default() },
Occ { part: 1, suppressed: true, ..Occ::default() },
Occ { part: 1, sons: vec![1], g: Some((0, 0)), ..Occ::default() },
];
let f = single(&[plate(S_RED)], &occs);
assert_eq!(f.placements().unwrap().len(), 1);
let nodes = f.model_tree().unwrap();
let flags: Vec<_> = nodes.iter().map(|n| (n.occurrence, n.hidden, n.suppressed, n.drawn)).collect();
assert_eq!(flags, [(0, false, false, true), (1, false, false, true), (2, false, true, false), (3, true, false, false), (1, false, false, false)]);
}
#[test]
fn a_father_herit_colour_beats_the_part_and_a_son_herit_colour_beats_that() {
let forced = (S_GREEN, SHOW | FATHER_HERIT_COLOR);
let occs = [Occ { sons: vec![1], g: Some(forced), ..Occ::default() }, Occ { part: 1, ..Occ::default() }];
assert_eq!(colours(&single(&[plate(S_RED)], &occs), EntityOverrides::default()), [Some(GREEN)]);
let son = part(None, PLAIN, 1, |w| poly_item(w, 7, 0, 1, (S_RED, SHOW | SON_HERIT_COLOR)));
assert_eq!(colours(&single(&[son], &occs), EntityOverrides::default()), [Some(RED)]);
// Unforced, the son's own style wins over its father's.
let occs = [Occ { sons: vec![1], g: Some((S_GREEN, SHOW)), ..Occ::default() }, Occ { part: 1, ..Occ::default() }];
assert_eq!(colours(&single(&[plate(S_RED)], &occs), EntityOverrides::default()), [Some(RED)]);
}
#[test]
fn resolve_style_takes_the_oldest_forcing_ancestor() {
let g = |style, bits| Graphics { style, bits, fs: 0 };
let chain = [g(1, FATHER_HERIT_COLOR), g(2, FATHER_HERIT_COLOR), g(0, 0), g(3, 0)];
assert_eq!(resolve_style(&chain).style, 1);
assert_eq!(resolve_style(&[g(1, 0), g(0, 0)]).style, 1);
assert_eq!(resolve_style(&[]).style, 0);
}
#[test]
fn a_prototypes_part_is_coloured_by_its_own_structures_styles() {
// Structure 1 is a part library whose style 1 is green; structure 0's style 1 is red.
let library = Fs {
globals: globals(&[GREEN], &[], &[style(1, false, None)], &[]),
tree: tree(&[plate(1)], &[Occ { name: Some("Bolt"), part: 1, ..Occ::default() }]),
tess: two_face_square(),
};
let assembly = Fs {
globals: globals(&[RED], &[], &[style(1, false, None)], &[]),
tree: tree(&[], &[Occ { prototype: 1, prototype_fs: Some(1), location: Some(translate([1., 2., 3.])), ..Occ::default() }]),
tess: W::default(),
};
let f = PrcFile::parse(&prc(&[assembly, library], &model(&[(0, 1)]))).unwrap();
let p = f.placements().unwrap();
assert_eq!(p.iter().map(|p| (p.file_structure, p.colour, p.matrix.at(1, 3))).collect::<Vec<_>>(), [(1, Some(GREEN), 2.)]);
let n = f.model_tree().unwrap();
assert_eq!((n[0].name.as_deref(), n[0].name_from), (Some("Bolt"), NameSource::Prototype));
}
/// A root placing the red plate twice, the first occurrence carrying `refs`.
fn shared_plate(refs: Vec<W>) -> PrcFile {
let occs = [Occ { sons: vec![1, 2], ..Occ::default() }, Occ { part: 1, refs, ..Occ::default() }, Occ { part: 1, ..Occ::default() }];
single(&[plate(S_RED)], &occs)
}
#[test]
fn an_entity_reference_reaches_its_scope() {
let f = shared_plate(vec![ref_entity(7, (S_GREEN, SHOW))]);
assert_eq!(colours(&f, EntityOverrides::Subtree), [Some(GREEN), Some(RED)]);
assert_eq!(colours(&f, EntityOverrides::Everywhere), [Some(GREEN), Some(GREEN)]);
assert_eq!(colours(&f, EntityOverrides::Ignore), [Some(RED), Some(RED)]);
// A reference to another uid changes nothing; one without Show hides its target.
assert_eq!(colours(&shared_plate(vec![ref_entity(8, (S_GREEN, SHOW))]), EntityOverrides::Subtree), [Some(RED); 2]);
assert_eq!(colours(&shared_plate(vec![ref_entity(7, (0, 0))]), EntityOverrides::Subtree), [Some(RED)]);
}
#[test]
fn a_topology_reference_colours_its_faces() {
let brep = part(None, PLAIN, 1, |w| brep_item(w, 7, (S_RED, SHOW), (4, 9)));
let occs = [Occ { part: 1, refs: vec![ref_faces((4, 9), &[1], (S_GREEN, SHOW))], ..Occ::default() }];
let f = single(&[brep], &occs);
let p = f.placements().unwrap();
assert_eq!(p[0].colour, Some(RED));
assert_eq!(p[0].triangle_colours(&mesh(&f, 0)), Some(vec![Some(RED), Some(GREEN)]));
// Another body's faces are left alone.
let occs = [Occ { part: 1, refs: vec![ref_faces((4, 8), &[1], (S_GREEN, SHOW))], ..Occ::default() }];
let f = single(&[part(None, PLAIN, 1, |w| brep_item(w, 7, (S_RED, SHOW), (4, 9)))], &occs);
assert_eq!(f.placements().unwrap()[0].triangle_colours(&mesh(&f, 0)), None);
}
#[test]
fn an_items_coordinate_system_applies_after_the_occurrence_location() {
let fs = Fs {
globals: globals(&[RED], &[], &[], &[translate([0., 0., 7.])]),
tree: tree(
&[part(None, PLAIN, 1, |w| poly_item(w, 7, 1, 1, PLAIN))],
&[Occ { part: 1, location: Some(translate_scale([1., 0., 0.], 2.)), ..Occ::default() }],
),
tess: two_face_square(),
};
let f = PrcFile::parse(&prc(&[fs], &model(&[(0, 1)]))).unwrap();
let p = f.placements().unwrap();
assert_eq!((p[0].matrix.at(0, 3), p[0].matrix.at(2, 3), p[0].colour), (1., 14., None));
}
#[test]
fn a_colour_index_counts_doubles() {
// pdf-issues #816: colour i is stored as 3i + 1.
let g =
|index| Globals { colours: vec![RED, GREEN], styles: vec![Style { is_material: false, index, transparency: None }], ..Globals::default() };
let paint = |index| g(index).style_paint(1, StyleAlpha::default()).map(|p| p.rgba);
assert_eq!(paint(1), Some(RED));
assert_eq!(paint(4), Some(GREEN));
assert_eq!(paint(2), None);
assert_eq!(paint(7), None);
}
#[test]
fn style_alpha_rules_combine_transparency_with_material_alpha() {
let g = |alpha, transparency| Globals {
colours: vec![BLUE],
materials: vec![Material::Plain { diffuse: 1, alpha }],
styles: vec![Style { is_material: true, index: 1, transparency }],
..Globals::default()
};
let a = |alpha, t, rule| g(alpha, t).style_paint(1, rule).unwrap().rgba.a;
assert_eq!(a(0., None, StyleAlpha::ZeroUnset), 1.);
assert_eq!(a(0., None, StyleAlpha::StyleWins), 0.);
assert_eq!(a(0.5, Some(51), StyleAlpha::StyleWins), 0.2);
assert_eq!(a(0.5, Some(51), StyleAlpha::Multiply), 0.1);
assert_eq!(a(f64::NAN, None, StyleAlpha::Multiply), 1.);
// A textured material takes its base's colour.
let mut t = g(1., None);
t.materials.push(Material::Textured { base: 1 });
t.styles[0].index = 2;
assert_eq!(t.style_paint(1, StyleAlpha::default()).unwrap().rgba, BLUE);
}
fn transformation(behaviour: u8, fields: &[f64]) -> super::Matrix {
let mut w = W::default();
w.uint(202).put(u64::from(behaviour), 8);
for &d in fields {
w.double(d);
}
let bytes = w.bytes();
Ctx::new(BitReader::new(&bytes), &Schema::default(), 8137).transformation().unwrap()
}
#[test]
fn a_mirrored_rotation_takes_y_cross_x_and_both_scales_multiply() {
let x_y = [1., 0., 0., 0., 1., 0.];
assert_eq!(transformation(0x02, &x_y).at(2, 2), 1.);
assert_eq!(transformation(0x02 | 0x04, &x_y).at(2, 2), -1.);
let m = transformation(0x10 | 0x08, &[1., 2., 3., 2.]);
assert_eq!([m.at(0, 0), m.at(1, 1), m.at(2, 2)], [2., 4., 6.]);
let m = transformation(0x40, &[0., 0., 0.5, 2.]);
assert_eq!(m.0[3], [0., 0., 0.5, 2.]);
}
#[test]
fn every_truncation_of_a_tree_or_globals_is_an_error_not_a_panic() {
let occs = [
Occ { name: Some("Asm"), sons: vec![1], location: Some(translate([0., 0., 1.])), ..Occ::default() },
Occ { part: 1, refs: vec![ref_entity(7, (S_GREEN, SHOW)), ref_faces((1, 1), &[0], PLAIN)], ..Occ::default() },
];
let t = tree(&[plate(S_RED)], &occs).bytes();
let schema = Schema::default();
for end in 0..t.len().saturating_sub(1) {
assert!(Ctx::new(BitReader::new(&t[..end]), &schema, 8137).file_structure_tree().is_err(), "tree cut at {end}");
}
let g = palette().bytes();
for end in 1..g.len().saturating_sub(1) {
let mut r = BitReader::new(&g[..end]);
if Schema::read(&mut r).is_ok() {
assert!(Ctx::new(r, &schema, 8137).globals().is_err(), "globals cut at {end}");
}
}
}
#[test]
fn a_prototype_chain_past_the_ceiling_is_an_error() {
// Occurrence k (1-based) has occurrence k + 1 as its prototype; none has a part.
let n = u32::try_from(super::MAX_DEPTH).unwrap() + 3;
let occs: Vec<_> = (1..=n).map(|k| Occ { prototype: if k < n { k + 1 } else { 0 }, ..Occ::default() }).collect();
assert!(malformed(single(&[], &occs).model_tree()).contains("prototype chain too long"));
}
/// `depth` representation sets, each holding the next, around one poly-B-rep item.
fn nested_sets(w: &mut W, depth: usize) {
if depth == 0 {
return poly_item(w, 0, 0, 1, PLAIN);
}
w.uint(super::RI_SET);
graphics(w, None, 0, PLAIN);
w.uint(0).uint(0).uint(1);
nested_sets(w, depth - 1);
w.uint(0); // user data
}
#[test]
fn representation_sets_nested_past_the_ceiling_are_an_error() {
let deep = part(None, PLAIN, 1, |w| nested_sets(w, super::MAX_DEPTH + 1));
let occs = [Occ { part: 1, ..Occ::default() }];
assert!(malformed(single(&[deep], &occs).placements()).contains("representation sets nest too deep"));
let ok = part(None, PLAIN, 1, |w| nested_sets(w, super::MAX_DEPTH));
assert_eq!(single(&[ok], &occs).placements().unwrap().len(), 1);
}
+262
View File
@@ -0,0 +1,262 @@
//! The occurrence walk that turns the trees into placements [WD 7.3.10.1, 7.6.3.2].
use super::overrides::{Active, Reached};
use super::{
EntityOverrides, Globals, Graphics, MAX_DEPTH, MAX_PLACEMENTS, MAX_VISITS, Matrix, ModelNode, NameSource, Override, Palettes, Placement, Product,
StyleAlpha, Target, Tree, UniqueId, paint_of, resolve_style, style,
};
use crate::PrcError;
use crate::container::offset;
use crate::entity::malformed;
/// Ceiling on face-override entries all placements of one walk carry together.
const MAX_FACE_OVERRIDES: usize = 1 << 24;
/// Where an occurrence sits: its depth, its parent node and whether its ancestors are drawn.
#[derive(Debug, Clone, Copy)]
pub(super) struct At {
depth: usize,
parent: Option<usize>,
drawn: bool,
}
impl At {
/// A root occurrence.
pub(super) const ROOT: At = At { depth: 0, parent: None, drawn: true };
}
/// An occurrence's part and sons as `(file structure, value)`, after the prototype fallback.
struct Resolved<'t> {
part: (usize, u32),
sons: (usize, &'t [u32]),
proto_name: Option<&'t str>,
/// The entity references in force: the occurrence's own, else its prototype chain's
/// first.
overrides: (usize, &'t [Override]),
}
/// The occurrence walk over every file structure's tree.
pub(super) struct Walk<'t> {
/// Per file structure: its id, tree and globals.
trees: Vec<(UniqueId, &'t Tree, &'t Globals)>,
pub(super) out: Vec<Placement>,
pub(super) nodes: Vec<ModelNode>,
visits: usize,
face_overrides: usize,
palettes: Palettes,
scope: EntityOverrides,
/// The entity references of the occurrences being walked, outermost first; under
/// [`EntityOverrides::Everywhere`], every occurrence's.
active: Vec<Active<'t>>,
/// The `(file structure, occurrence)` of each occurrence being walked, root first: an
/// occurrence met again on its own path is a cycle.
path: Vec<(usize, usize)>,
}
impl<'t> Walk<'t> {
/// A walk over `trees`, with no placements yet, colouring by `rule` and applying entity
/// references by `scope`.
pub(super) fn new(trees: Vec<(UniqueId, &'t Tree, &'t Globals)>, rule: StyleAlpha, scope: EntityOverrides) -> Self {
let palettes = trees
.iter()
.map(|(_, _, g)| (0..=g.styles.len()).map(|b| u32::try_from(b).ok().and_then(|b| g.style_paint(b, rule))).collect())
.collect();
let mut walk =
Walk { trees, out: Vec::new(), nodes: Vec::new(), visits: 0, face_overrides: 0, palettes, scope, active: Vec::new(), path: Vec::new() };
if scope == EntityOverrides::Everywhere {
let trees = walk.trees.clone();
for (fs, t) in trees.iter().enumerate() {
for p in &t.1.products {
walk.activate(fs, &p.overrides);
}
}
}
walk
}
/// Puts `overrides`, held by an occurrence of structure `owner`, in force. One naming an
/// unknown structure is left out.
fn activate(&mut self, owner: usize, overrides: &'t [Override]) {
for ov in overrides {
let named = match (ov.target.fs(), &ov.target) {
(Some(id), _) => match self.fs(id) {
Ok(n) => Some(n),
Err(_) => continue,
},
(None, Target::Entity { .. }) => Some(owner),
(None, Target::Topology { .. }) => None,
};
self.active.push((owner, named, ov));
}
}
fn fs(&self, id: UniqueId) -> Result<usize, PrcError> {
self.trees.iter().position(|t| t.0 == id).ok_or_else(|| malformed("reference to an unknown file structure".into()))
}
fn product(&self, fs: usize, index: usize) -> Result<&'t Product, PrcError> {
self.trees.get(fs).and_then(|t| t.1.products.get(index)).ok_or_else(|| malformed(format!("product occurrence {index} does not exist")))
}
/// The structure a file identifier names: `id`, or `fs` itself when it names none.
fn named(&self, id: Option<UniqueId>, fs: usize) -> Result<usize, PrcError> {
id.map_or(Ok(fs), |id| self.fs(id))
}
/// Walks occurrence `index` of structure `fs` under a father placed at `father`, whose
/// occurrences' graphics are `graphics` (root first), and lists it as a node. A hidden or
/// suppressed subtree is listed with `drawn` false and placed nowhere.
pub(super) fn occurrence(&mut self, fs: usize, index: usize, father: &Matrix, graphics: &[Graphics], at: At) -> Result<(), PrcError> {
self.visits = self.visits.saturating_add(1);
if at.depth > MAX_DEPTH || self.visits > MAX_VISITS {
return Err(malformed("the occurrence tree is too deep or too large".into()));
}
if self.path.contains(&(fs, index)) {
return Err(malformed(format!("product occurrence {index} contains itself")));
}
let p = self.product(fs, index)?;
let m = p.location.map_or(*father, |l| father.multiply(&l));
let mut chain = graphics.to_vec();
chain.push(Graphics { fs, ..p.graphics });
let r = self.resolve(fs, index, p)?;
let drawn = at.drawn && !p.hidden && !p.suppressed;
let in_force = self.active.len();
if self.scope == EntityOverrides::Subtree {
self.activate(r.overrides.0, r.overrides.1);
}
let node = self.nodes.len();
let first = self.out.len();
let (name, name_from) = self.name_of(p, &r);
self.nodes.push(ModelNode {
name,
name_from,
parent: at.parent,
depth: at.depth,
file_structure: fs,
occurrence: index,
hidden: p.hidden,
suppressed: p.suppressed,
drawn,
has_part: r.part.1 != 0,
placements: first..first,
});
if let (true, Some(part)) = (drawn, r.part.1.checked_sub(1)) {
self.part(r.part.0, offset(part), &m, &chain)?;
}
// Son indices are 0-based; the external one is biased.
let external = match p.external.checked_sub(1) {
Some(e) => Some((self.named(p.external_fs, fs)?, e)),
None => None,
};
let child = At { depth: at.depth + 1, parent: Some(node), drawn };
self.path.push((fs, index));
let walked =
r.sons.1.iter().map(|&s| (r.sons.0, s)).chain(external).try_for_each(|(sfs, s)| self.occurrence(sfs, offset(s), &m, &chain, child));
self.path.pop();
walked?;
self.active.truncate(in_force);
let end = self.out.len();
if let Some(n) = self.nodes.get_mut(node) {
n.placements = first..end;
}
Ok(())
}
/// The part and sons of `p` (occurrence `index` of `fs`), falling back to its prototype
/// chain's for whichever it leaves empty [WD 7.3.10.1], and the first name on that chain.
fn resolve(&self, fs: usize, index: usize, p: &'t Product) -> Result<Resolved<'t>, PrcError> {
let (mut part, mut sons) = ((fs, p.part), (fs, p.sons.as_slice()));
let mut proto_name = None;
let mut overrides = (fs, p.overrides.as_slice());
let mut proto = (fs, p.prototype, p.prototype_fs);
let mut seen = vec![(fs, index)];
while proto.1 != 0 && (part.1 == 0 || sons.1.is_empty() || proto_name.is_none() || overrides.1.is_empty()) {
let pfs = self.named(proto.2, proto.0)?;
let pi = offset(proto.1.saturating_sub(1));
if seen.contains(&(pfs, pi)) {
return Err(malformed(format!("product occurrence {pi} is its own prototype")));
}
if seen.len() > MAX_DEPTH {
return Err(malformed("prototype chain too long".into()));
}
seen.push((pfs, pi));
let q = self.product(pfs, pi)?;
if part.1 == 0 {
part = (pfs, q.part);
}
if sons.1.is_empty() {
sons = (pfs, q.sons.as_slice());
}
if proto_name.is_none() {
proto_name = q.name.as_deref();
}
if overrides.1.is_empty() {
overrides = (pfs, q.overrides.as_slice());
}
proto = (pfs, q.prototype, q.prototype_fs);
}
Ok(Resolved { part, sons, proto_name, overrides })
}
/// The display name: the occurrence's, its prototype chain's, then its part's.
fn name_of(&self, p: &Product, r: &Resolved<'_>) -> (Option<String>, NameSource) {
if let Some(n) = &p.name {
return (Some(n.clone()), NameSource::Occurrence);
}
if let Some(n) = r.proto_name {
return (Some(n.to_owned()), NameSource::Prototype);
}
let part = r.part.1.checked_sub(1).and_then(|i| self.trees.get(r.part.0)?.1.part_names.get(offset(i))).and_then(Option::as_ref);
match part {
Some(n) => (Some(n.clone()), NameSource::Part),
None => (None, NameSource::Unnamed),
}
}
/// Places part definition `index` of structure `fs` at `m`, under `graphics`.
fn part(&mut self, fs: usize, index: usize, m: &Matrix, graphics: &[Graphics]) -> Result<(), PrcError> {
let (_, tree, _) = self.trees.get(fs).copied().ok_or_else(|| malformed("file structure out of range".into()))?;
let items = tree.parts.get(index).ok_or_else(|| malformed(format!("part definition {index} does not exist")))?;
for item in items {
let reached = Reached::collect(&self.active, fs, item);
if reached.hidden {
continue;
}
if self.out.len() >= MAX_PLACEMENTS {
return Err(malformed(format!("more than {MAX_PLACEMENTS} placements")));
}
self.face_overrides = self.face_overrides.saturating_add(reached.faces.len());
if self.face_overrides > MAX_FACE_OVERRIDES {
return Err(malformed("too many face overrides".into()));
}
let matrix = match reached.local {
Some((owner, cs)) => self.located(m, owner, &[cs])?,
None => self.located(m, fs, &item.local)?,
};
let chain: Vec<Graphics> = graphics.iter().copied().chain(item.graphics.iter().map(|&g| Graphics { fs, ..g })).collect();
let won = reached.item.unwrap_or_else(|| resolve_style(&chain));
self.out.push(Placement {
file_structure: fs,
tessellation: offset(item.tessellation.saturating_sub(1)),
matrix,
colour: paint_of(&self.palettes, won).map(|p| p.rgba),
palettes: self.palettes.clone(),
chain,
item_override: reached.item,
face_overrides: reached.faces,
});
}
Ok(())
}
/// `m` × each of structure `fs`'s coordinate systems `local` (biased).
fn located(&self, m: &Matrix, fs: usize, local: &[u32]) -> Result<Matrix, PrcError> {
let globals = self.trees.get(fs).ok_or_else(|| malformed("file structure out of range".into()))?.2;
let mut placed = *m;
for &cs in local {
let l = style::biased(&globals.systems, cs).ok_or_else(|| malformed(format!("coordinate system {cs} does not exist")))?;
placed = placed.multiply(l);
}
Ok(placed)
}
}
+140
View File
@@ -0,0 +1,140 @@
//! The PRC container walk [WD 6.1-6.2] against synthetic streams.
#![allow(clippy::unwrap_used)]
use std::io::Write;
use pdfcraft_prc::{MAX_FILE_STRUCTURES, PrcError, PrcFile, SectionKind, UniqueId};
fn zlib(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn u32s(out: &mut Vec<u8>, vs: &[u32]) {
for v in vs {
out.extend_from_slice(&v.to_le_bytes());
}
}
/// One file structure with the given section payloads and model file.
fn build(sections: [&[u8]; 5], model: &[u8], padding: usize) -> Vec<u8> {
const HEADER_LEN: usize = 107;
let mut fs_header = b"PRC".to_vec();
u32s(&mut fs_header, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 1, 3]);
fs_header.extend_from_slice(b"\xff\xd8\xff");
let mut body = fs_header.clone();
let mut offs = vec![HEADER_LEN];
for s in sections {
offs.push(HEADER_LEN + body.len());
body.extend(zlib(s));
body.extend(std::iter::repeat_n(0u8, padding));
}
let mf_start = HEADER_LEN + body.len();
body.extend(zlib(model));
let mf_end = HEADER_LEN + body.len();
let mut out = b"PRC".to_vec();
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]);
u32s(&mut out, &[5, 6, 7, 8, 0, 6]);
u32s(&mut out, &offs.iter().map(|&o| o as u32).collect::<Vec<_>>());
u32s(&mut out, &[mf_start as u32, mf_end as u32, 0]);
assert_eq!(out.len(), HEADER_LEN);
out.extend(body);
out
}
const SECTIONS: [&[u8]; 5] = [b"globals", b"tree", b"tess", b"", b"extra"];
#[test]
fn the_container_walk_inflates_every_section() {
for padding in [0, 3] {
let f = PrcFile::parse(&build(SECTIONS, b"model file", padding)).unwrap();
assert_eq!(f.header.min_version_for_read, 8137);
assert_eq!(f.header.file_id, UniqueId([1, 2, 3, 4]));
assert_eq!(f.file_structures.len(), 1);
let fs = &f.file_structures[0];
assert_eq!(fs.id, UniqueId([5, 6, 7, 8]));
assert_eq!(fs.pictures, vec![b"\xff\xd8\xff".to_vec()]);
for (kind, want) in SectionKind::ALL.into_iter().zip(SECTIONS) {
assert_eq!(fs.section(kind), want, "{}", kind.name());
}
assert_eq!(f.model_file, b"model file");
}
}
#[test]
fn damaged_containers_are_refused_by_name() {
let good = build(SECTIONS, b"m", 0);
assert_eq!(PrcFile::parse(b"U3D\0").unwrap_err(), PrcError::NotPrc);
assert!(matches!(PrcFile::parse(&good[..40]), Err(PrcError::Truncated(_))));
// Reserved field (offset 63) not zero.
let mut bad = good.clone();
bad[63] = 1;
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_))));
// File-structure id disagrees with its description (FS header id at 107+11).
let mut bad = good.clone();
bad[118] ^= 0xff;
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_))));
// A section that is not zlib: corrupt the globals stream's header byte.
let globals = u32::from_le_bytes(good[75..79].try_into().unwrap()) as usize;
let mut bad = good.clone();
bad[globals] = 0;
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Inflate { section: "globals", .. })));
// Offsets out of order.
let mut bad = good;
bad[75..79].copy_from_slice(&1u32.to_le_bytes());
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_))));
}
#[test]
fn the_inflation_ceiling_is_reported_not_exceeded() {
// 1,200 inflated bytes and the 3-byte picture.
let f = build([&[0u8; 600], &[0u8; 600], b"", b"", b""], b"", 0);
assert!(PrcFile::parse_with_limit(&f, 1203).is_ok());
assert_eq!(PrcFile::parse_with_limit(&f, 1202).unwrap_err(), PrcError::TooLarge { limit: 1202 });
}
#[test]
fn copied_picture_blocks_are_charged_to_the_ceiling() {
// Nothing inflates to more than 0 bytes; the header's one picture is 3 bytes.
let f = build([b"", b"", b"", b"", b""], b"", 0);
assert!(PrcFile::parse_with_limit(&f, 3).is_ok());
assert_eq!(PrcFile::parse_with_limit(&f, 2).unwrap_err(), PrcError::TooLarge { limit: 2 });
}
#[test]
fn an_offset_or_count_past_the_data_is_an_error_not_a_panic() {
let mut bad = build(SECTIONS, b"m", 0);
// The model file's start (header offset 95) far past the end, its end further still.
bad[95..99].copy_from_slice(&u32::MAX.to_le_bytes());
bad[99..103].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(PrcFile::parse(&bad).is_err());
// A model file ending one byte past the data, its zlib stream whole.
let mut bad = build(SECTIONS, b"m", 0);
let past = u32::try_from(bad.len() + 1).unwrap();
bad[99..103].copy_from_slice(&past.to_le_bytes());
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_))));
// A section count that claims more offsets than the data holds.
let mut bad = build(SECTIONS, b"m", 0);
bad[67..71].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Truncated(_))));
}
#[test]
fn no_file_structures_or_more_than_the_cap_is_refused_by_name() {
let good = build(SECTIONS, b"m", 0);
// The file-structure count follows the magic, two versions and two ids.
assert_eq!(good[43..47], 1u32.to_le_bytes());
for n in [0, u32::try_from(MAX_FILE_STRUCTURES).unwrap() + 1] {
let mut bad = good.clone();
bad[43..47].copy_from_slice(&n.to_le_bytes());
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(m)) if m == format!("{n} file structures")), "{n}");
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Arbitrary bytes through every reader: an error or a value, never a panic.
use pdfcraft_prc::{BitReader, PrcFile, Schema};
use proptest::prelude::*;
proptest! {
#[test]
fn any_bytes_parse_or_fail_cleanly(data in proptest::collection::vec(any::<u8>(), 0..512), magic in any::<bool>()) {
let mut data = data;
if magic && data.len() >= 3 {
data[..3].copy_from_slice(b"PRC");
}
let _ = PrcFile::parse_with_limit(&data, 1 << 20);
}
#[test]
fn any_bits_read_or_fail_cleanly(data in proptest::collection::vec(any::<u8>(), 0..64), n in 0u32..40) {
let mut r = BitReader::new(&data);
let _ = r.unsigned_integer();
let _ = r.integer();
let _ = r.integer_vbn(n);
let _ = r.double_vbn(0.5, n);
let _ = r.nbits_then_unsigned();
let _ = r.string();
let _ = r.float_as_bytes();
let _ = r.double();
let _ = r.skip_bits(usize::try_from(n).unwrap_or(0));
prop_assert!(r.position() <= data.len() * 8);
}
#[test]
fn any_schema_runs_or_fails_cleanly(data in proptest::collection::vec(any::<u8>(), 0..256), entity in 0u32..4) {
let mut r = BitReader::new(&data);
if let Ok(schema) = Schema::read(&mut r) {
let types: Vec<u32> = schema.entity_types().chain([entity]).collect();
for t in types {
let _ = schema.skip_added_fields(t, &mut r.clone());
}
}
}
}
+3
View File
@@ -8756,6 +8756,9 @@ mod default_colour_spaces;
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
mod devicen_inks;
// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35).
mod png_predicted_images;
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
// patch 27).
mod isolated_cmyk_groups;
@@ -0,0 +1,69 @@
//! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a
//! time (vendored hayro-syntax patch 35). The bit-level decoder they went through before dropped
//! the last byte of each filtered row that ends part-way through a pixel, so every later row
//! started a byte early and the picture came out sheared.
use super::rgb_at;
use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
use flate2::{Compression, write::ZlibEncoder};
use std::io::Write;
use std::sync::Arc;
/// The page is 150 × 200 pt, drawn at 72 dpi; the image fills it.
const WIDTH: usize = 150;
const HEIGHT: usize = 200;
/// The colour of each of the image's four rows, top first, as 4-bit samples.
const ROWS: [[u8; 3]; 4] = [[15, 0, 0], [0, 15, 0], [0, 0, 15], [15, 15, 0]];
#[test]
fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() {
// Three 4-bit RGB pixels are 4.5 bytes, so each row is five bytes and ends half-way through a
// two-byte pixel.
let rows: Vec<Vec<u8>> = ROWS
.iter()
.map(|rgb| {
let nibbles: Vec<u8> = (0..3).flat_map(|_| rgb.iter().copied()).chain([0]).collect();
nibbles.chunks(2).map(|pair| (pair[0] << 4) | pair[1]).collect()
})
.collect();
let mut predicted = Vec::new();
let mut above = vec![0u8; 5];
for row in &rows {
predicted.push(2);
predicted.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a)));
above.clone_from(row);
}
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&predicted).unwrap();
let data = encoder.finish().unwrap();
let content = "q 150 0 0 200 0 0 cm /Im0 Do Q";
let mut pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 4 \
/Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /Length {} >> stream\n",
content.len(),
data.len()
)
.into_bytes();
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32));
// Each image pixel is 50 pt square; look at the middle of each.
for (row, rgb) in ROWS.iter().enumerate() {
let expected = rgb.map(|sample| sample * 17);
for column in 0..3 {
let (x, y) = (column * 50 + 25, row * 50 + 25);
assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}");
}
}
}
+1
View File
@@ -260,6 +260,7 @@ impl PdfCraftApp {
fonts_cjk: theme::CjkPreference::Default,
control: None,
bookmark_rename: None,
bookmark_clipboard: None,
last_opened_url: None,
community_card_closed: false,
pending_link: None,
+9 -4
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::file_filter::AddFilterAnyCase as _;
/// Whether `a` and `b` name the same file: equal, or equal once made absolute against the
/// current directory (links are not resolved, as in `last_session`).
@@ -705,7 +706,9 @@ impl PdfCraftApp {
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE)
rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE)
}
pub fn open_dialog(&mut self) {
@@ -713,7 +716,9 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
self.pick(
pickers::PickFor::Open,
rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE),
rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE),
true,
);
// Browsers pick files asynchronously; the bytes arrive through `inbox`.
@@ -723,8 +728,8 @@ impl PdfCraftApp {
let ctx = self.ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(h) = rfd::AsyncFileDialog::new()
.add_filter("PDF", &["pdf"])
.add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE)
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE)
.pick_file()
.await
{
+3 -1
View File
@@ -3,6 +3,8 @@
//! page the range selects, with the marks laid out on it. "Update" opens the same dialog in
//! replace mode.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::collections::{HashMap, HashSet};
use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2};
@@ -777,7 +779,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
#[cfg(not(target_arch = "wasm32"))]
if browse {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]);
.add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]);
let epoch = app.dialog_epoch();
app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| {
// Only into the dialog that asked: not one closed meanwhile, or opened again since.
+56
View File
@@ -977,6 +977,23 @@ impl PdfCraftApp {
let at = parent[parent.len() - 1] + 1;
Edit::MoveBookmark { from: path, to_parent: grand, index: at }
}
A::Copy(path) => {
self.copy_bookmark(id, &path);
return;
}
A::Cut(path) => match self.copy_bookmark(id, &path) {
Some(false) => Edit::DeleteBookmark { path },
// Paste could not bring back what the copy left out, so the bookmarks stay.
Some(true) => {
self.notify_tr("Bookmarks nested too deep to list could not be copied, so nothing was cut.");
return;
}
None => return,
},
A::Paste { parent, index } => {
self.paste_bookmarks(id, parent, index);
return;
}
A::FromStructure => {
self.right = Some(crate::RightPanel::Bookmarks);
Edit::BookmarksFromStructure
@@ -991,6 +1008,45 @@ impl PdfCraftApp {
}
}
impl PdfCraftApp {
/// Put the bookmark at `path` and its subtree on the bookmark clipboard. `Some(true)` when
/// bookmarks nested too deep to list were left out; `None` if it could not be read (the reason
/// is shown).
fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> Option<bool> {
match self.session.outline_tree(id, path) {
Some(Ok(tree)) => {
if tree.truncated {
self.notify_tr("Bookmarks nested too deep to list were left out of the copy.");
}
self.bookmark_clipboard = Some(tree.nodes);
Some(tree.truncated)
}
Some(Err(e)) => {
self.notify_error(e);
None
}
None => None,
}
}
/// Paste the bookmark clipboard as one undoable edit. Bookmarks whose page this document lacks
/// arrive without a destination, and the toast says how many.
fn paste_bookmarks(&mut self, id: pdfcraft_engine::DocId, parent: Vec<usize>, index: usize) {
let Some(bookmarks) = self.bookmark_clipboard.clone() else { return };
let pages = self.session.get(id).map_or(0, |d| d.info.pages.len());
let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, pages).len();
if self.apply_edit(Edit::ImportBookmarks { parent, index, bookmarks, replace: false }) && unmapped > 0 {
let n = u64::try_from(unmapped).unwrap_or(u64::MAX);
self.notify(crate::i18n::trn(
crate::i18n::current(),
n,
"{n} pasted bookmark points past the last page of this document and has no destination.",
"{n} pasted bookmarks point past the last page of this document and have no destination.",
));
}
}
}
fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> Option<&'a pdfcraft_render::OutlineItem> {
let (first, rest) = path.split_first()?;
let item = items.get(*first)?;
+3 -1
View File
@@ -1,4 +1,6 @@
use super::{PdfCraftApp, StagingName, staging_suffixes};
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
/// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file
/// on disk is another version than the document came from (#2166).
@@ -58,7 +60,7 @@ impl PdfCraftApp {
(SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p,
_ => {
let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") };
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name);
let dialog = in_document_folder(dialog, doc_path.as_deref());
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
+4
View File
@@ -126,6 +126,7 @@ pub use persist::units;
#[cfg(not(target_arch = "wasm32"))]
pub use persist::window_state;
use platform::drag_pointer;
use platform::file_filter;
#[cfg(not(target_arch = "wasm32"))]
pub use platform::file_watch;
#[cfg(target_os = "macos")]
@@ -611,6 +612,9 @@ pub struct PdfCraftApp {
control: Option<control::Control>,
/// A bookmark being renamed in the Bookmarks panel: (path, text so far).
pub bookmark_rename: Option<(Vec<usize>, String)>,
/// Bookmarks copied or cut in the Bookmarks panel, with their subtrees, ready to paste into
/// any document. Destinations are page indices.
pub bookmark_clipboard: Option<Vec<pdfcraft_engine::OutlineNode>>,
/// Number pages dialog settings (1-based pages).
pub number_draft: NumberDraft,
pub(crate) word_count_draft: word_count_ui::Draft,
+16
View File
@@ -19,6 +19,15 @@ pub enum BmAction {
Indent(Vec<usize>),
/// Move it out to follow its parent.
Outdent(Vec<usize>),
/// Copy the bookmark and everything under it to the bookmark clipboard.
Copy(Vec<usize>),
/// Copy it, then delete it (one undo step: only the delete changes the document).
Cut(Vec<usize>),
/// Paste the bookmark clipboard under `parent` at `index` (past the end appends).
Paste {
parent: Vec<usize>,
index: usize,
},
/// Bookmarks from the tagged headings, under a new first "Untitled" bookmark.
FromStructure,
/// Open New bookmarks from text styles, to choose a style per bookmark level (#1693).
@@ -55,6 +64,8 @@ pub(super) struct OutlineCtx<'a> {
pub(super) action: &'a mut Option<BmAction>,
pub(super) rename: &'a mut Option<(Vec<usize>, String)>,
pub(super) editable: bool,
/// The bookmark clipboard holds something to paste.
pub(super) can_paste: bool,
pub(super) current: usize,
/// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame.
pub(super) expand: Option<usize>,
@@ -242,6 +253,11 @@ pub(super) fn outline_item(
pick(ui, tl!("Indent"), i > 0, BmAction::Indent(path.to_vec()));
pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec()));
ui.separator();
pick(ui, tl!("Cut"), true, BmAction::Cut(path.to_vec()));
pick(ui, tl!("Copy"), true, BmAction::Copy(path.to_vec()));
let parent = path.get(..depth).unwrap_or_default().to_vec();
pick(ui, tl!("Paste"), cx.can_paste, BmAction::Paste { parent, index: i.saturating_add(1) });
ui.separator();
pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec()));
if let Some(a) = styled {
*cx.action = Some(a);
+6
View File
@@ -24,6 +24,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let mut compare_action: Option<crate::compare_ui::PanelAction> = None;
let mut bm_rename = app.bookmark_rename.clone();
let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none());
let bm_can_paste = app.bookmark_clipboard.is_some();
// Prepare a form is open: the Fields panel orders tabs.
let preparing = app.is_preparing();
{
@@ -111,6 +112,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
bm_action = Some(BmAction::FromTextStyles);
ui.close();
}
if bm_editable && ui.add_enabled(bm_can_paste, egui::Button::new(tl!("Paste bookmarks"))).clicked() {
bm_action = Some(BmAction::Paste { parent: Vec::new(), index: usize::MAX });
ui.close();
}
if !info.outline.is_empty() {
for (levels, label) in [
(usize::MAX, tl!("Expand all bookmarks")),
@@ -195,6 +200,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
action: &mut bm_action,
rename: &mut bm_rename,
editable: bm_editable,
can_paste: bm_can_paste,
current: view.current,
expand: bm_expand,
matches: &bookmark_matches,
+1
View File
@@ -1,4 +1,5 @@
pub(super) mod drag_pointer;
pub(super) mod file_filter;
#[cfg(not(target_arch = "wasm32"))]
pub mod file_watch;
#[cfg(target_os = "macos")]
@@ -0,0 +1,50 @@
//! Picker filters that list a file whatever the letter case of its extension.
//!
//! On Linux and the BSDs, rfd gives each extension to the XDG portal, GTK or Zenity as the glob
//! `*.<ext>`, and those match it case-sensitively: a "PDF" filter of `pdf` hid `Report.PDF`.
//! Windows, macOS and the browser already ignore case, so there the extension passes unchanged.
/// Whether the platform's pickers match filter globs case-sensitively.
const CASE_SENSITIVE_GLOBS: bool = cfg!(all(unix, not(target_os = "macos")));
/// [`rfd::AsyncFileDialog::add_filter`], with each extension matched in any letter case.
pub(crate) trait AddFilterAnyCase {
#[must_use]
fn add_filter_any_case(self, name: impl Into<String>, extensions: &[impl AsRef<str>]) -> Self;
}
impl AddFilterAnyCase for rfd::AsyncFileDialog {
fn add_filter_any_case(self, name: impl Into<String>, extensions: &[impl AsRef<str>]) -> Self {
let globs: Vec<String> = extensions.iter().map(|e| any_case(e.as_ref(), CASE_SENSITIVE_GLOBS)).collect();
self.add_filter(name, &globs)
}
}
/// `ext` as a glob fragment matching any letter case: `pdf` becomes `[pP][dD][fF]`.
fn any_case(ext: &str, case_sensitive: bool) -> String {
if !case_sensitive {
return ext.to_owned();
}
ext.chars()
.map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() })
.collect()
}
#[cfg(test)]
mod tests {
use super::any_case;
#[test]
fn a_case_sensitive_picker_gets_a_glob_matching_every_case() {
assert_eq!(any_case("pdf", true), "[pP][dD][fF]");
assert_eq!(any_case("PDF", true), "[pP][dD][fF]");
assert_eq!(any_case("jp2", true), "[jJ][pP]2");
assert_eq!(any_case("*", true), "*");
}
#[test]
fn a_case_blind_picker_gets_the_extension_unchanged() {
assert_eq!(any_case("pdf", false), "pdf");
assert_eq!(any_case("*", false), "*");
}
}
+3 -1
View File
@@ -3,6 +3,8 @@
//! (images keep their proportions), double-click text to retype it, format it from the panel,
//! Delete to remove it. Each change is one undoable engine edit.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
use pdfcraft_render::DocInfo;
@@ -897,7 +899,7 @@ impl crate::PdfCraftApp {
Some(_) => {}
None => {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.set_title(title);
let target = self.active_ids().map(|(_, id)| id);
self.ask_one(crate::pickers::Ask::File(dialog), target, read);
+2 -1
View File
@@ -3,6 +3,7 @@
//! signature. Everything is an annotation (typewriter text, PdfCraft-drawn stamps, ink), so it
//! can be moved, deleted and undone like any comment.
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError};
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn};
@@ -787,7 +788,7 @@ impl crate::PdfCraftApp {
let epoch = self.dialog_epoch();
let initials = self.signature_draft.initials;
let scan = self.signature_draft.scan_mode;
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]);
#[cfg(not(target_arch = "wasm32"))]
self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
if app.dialog_epoch() != epoch
+3 -1
View File
@@ -1,4 +1,6 @@
//! Measure objects: page-space clicks, live readings and persistent drawing scales.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets};
use egui::{Color32, Pos2, Stroke};
use pdfcraft_engine::{
@@ -627,7 +629,7 @@ fn export_csv(app: &mut PdfCraftApp) {
match app.export_dir_override.as_ref().map(|d| std::path::PathBuf::from(d).join("measurements.csv")) {
Some(path) => write(app, path),
None => {
let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter("CSV", &["csv"]);
let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter_any_case("CSV", &["csv"]);
app.ask_one(crate::pickers::Ask::Save(dialog), None, write);
}
}
+4 -2
View File
@@ -2,6 +2,8 @@
//! click an empty signature field), the Sign with a Digital ID / Configure New Digital ID /
//! Sign as dialogs, the Signatures panel, and the signature message bar.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::path::PathBuf;
use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2};
@@ -494,7 +496,7 @@ impl PdfCraftApp {
Some(p) => sign_and_save(self, PathBuf::from(p)),
#[cfg(not(target_arch = "wasm32"))]
None => {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf"));
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf"));
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
// The signature's page, rectangle and field refer to the document as it is now:
// the pick is dropped (with a notice) if it is edited or switched away from
@@ -526,7 +528,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn pick_sign_graphic_image(&mut self) {
let epoch = self.dialog_epoch();
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Image"), &["png", "jpg", "jpeg"]);
self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| {
if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Sign) {
return;
+3 -1
View File
@@ -2,6 +2,8 @@ use super::{
Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic,
SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets,
};
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
/// Draw the signing dialogs; returns `true` when the dialog should close.
pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
@@ -233,7 +235,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
// The picker answers on a later frame, into the draft if the same dialog is still open.
#[cfg(not(target_arch = "wasm32"))]
if browse {
let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Digital ID"), &["p12", "pfx"]);
let epoch = app.dialog_epoch();
app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| {
// Not one closed meanwhile, or opened again since.
+3 -1
View File
@@ -1,6 +1,8 @@
//! Custom stamps (Add a stamp ▸ Custom stamps ▸ Create): a PDF page or an image saved in the
//! user's stamp library under a category and a name, then placed like any stamp.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::Arc;
use egui::{Align, Layout};
@@ -195,7 +197,7 @@ impl PdfCraftApp {
Some(_) => {}
None => {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.set_title(tl!("Select a file for the stamp"));
self.ask_one(crate::pickers::Ask::File(dialog), None, read);
}
+3 -1
View File
@@ -1,6 +1,8 @@
//! Action Wizard: the actions (PdfCraft's and your own), running one over files in the
//! background, and creating or editing an action's steps. Your actions are remembered.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::{Arc, Mutex};
use egui::{Align, Layout};
@@ -186,7 +188,7 @@ impl PdfCraftApp {
match self.action_files_override.clone() {
Some(p) => run(self, p.into_iter().map(std::path::PathBuf::from).collect()),
None => {
let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter("PDF", &["pdf"]);
let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter_any_case("PDF", &["pdf"]);
self.ask(crate::pickers::Ask::Files(dialog), None, run);
}
}
+4 -2
View File
@@ -1,6 +1,8 @@
//! 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.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::Arc;
use crate::PdfCraftApp;
@@ -172,7 +174,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
{
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"])
.set_title(tl!("Choose images"));
self.ask(crate::pickers::Ask::Files(dialog), None, |app, files| {
let mut images = Vec::new();
@@ -303,7 +305,7 @@ impl PdfCraftApp {
match self.save_override.clone() {
Some(p) => write(self, p),
None => {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name);
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name);
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned()));
}
+21 -20
View File
@@ -3,6 +3,7 @@
//! Files are picked asynchronously and used on a later frame: desktop builds through `pickers`
//! (a blocking picker crashes the app on macOS), browsers through `requests`.
use crate::file_filter::AddFilterAnyCase as _;
use std::sync::Arc;
use pdfcraft_engine::{DocId, Edit, SplitBy};
@@ -93,16 +94,16 @@ pub(crate) fn installed_font_files(ctx: &egui::Context, reload: bool) -> Option<
fn files_picker(purpose: FilePurpose) -> rfd::AsyncFileDialog {
let dialog = rfd::AsyncFileDialog::new();
if matches!(purpose, FilePurpose::TextFont { .. }) {
return dialog.add_filter("TrueType / OpenType font", &["ttf", "otf"]);
return dialog.add_filter_any_case("TrueType / OpenType font", &["ttf", "otf"]);
}
if purpose == FilePurpose::RedactWords {
return dialog.add_filter(tl!("Text"), &["txt"]);
return dialog.add_filter_any_case(tl!("Text"), &["txt"]);
}
if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::CreateMultipleSeparate | FilePurpose::InsertPages | FilePurpose::Combine) {
return dialog.add_filter("PDF", &["pdf"]);
return dialog.add_filter_any_case("PDF", &["pdf"]);
}
let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect();
dialog.add_filter(tl!("PDF, images and text"), &all).add_filter("PDF", &["pdf"])
dialog.add_filter_any_case(tl!("PDF, images and text"), &all).add_filter_any_case("PDF", &["pdf"])
}
/// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive).
@@ -930,7 +931,7 @@ impl PdfCraftApp {
Some(_) => {}
None => {
let dialog = rfd::AsyncFileDialog::new()
.add_filter(tl!("Comment and form data").to_string(), &extensions)
.add_filter_any_case(tl!("Comment and form data").to_string(), &extensions)
.set_title(tl!("Import data").to_string());
let target = self.active_ids().map(|(_, id)| id);
self.ask_one(crate::pickers::Ask::File(dialog), target, import);
@@ -971,7 +972,7 @@ impl PdfCraftApp {
None => {
let dialog = rfd::AsyncFileDialog::new()
.set_title(tl!("Export").to_string())
.add_filter(ext.to_uppercase(), &[ext])
.add_filter_any_case(ext.to_uppercase(), &[ext])
.set_file_name(format!("{stem}.{ext}"));
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(dialog), None, write);
@@ -991,7 +992,7 @@ impl PdfCraftApp {
{
let dialog = rfd::AsyncFileDialog::new()
.set_title(tl!("Select data files to merge").to_string())
.add_filter(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]);
.add_filter_any_case(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]);
self.ask(crate::pickers::Ask::Files(dialog), None, |app, paths| {
let mut files = Vec::new();
for p in paths {
@@ -1036,7 +1037,7 @@ impl PdfCraftApp {
None => {
let dialog = rfd::AsyncFileDialog::new()
.set_title(tl!("Save the spreadsheet").to_string())
.add_filter("CSV", &["csv"])
.add_filter_any_case("CSV", &["csv"])
.set_file_name("report.csv");
self.ask_one(crate::pickers::Ask::Save(dialog), None, write);
}
@@ -1079,22 +1080,22 @@ impl PdfCraftApp {
let title = if comments { tl!("Export comments") } else { tl!("Export form data") };
let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf"));
let d = if comments {
d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"])
d.add_filter_any_case("XFDF", &["xfdf"]).add_filter_any_case("FDF", &["fdf"])
} else if xfa {
// An XFA form's data, as Acrobat exports it, first.
d.set_file_name(format!("{stem}.xml"))
.add_filter("XML", &["xml"])
.add_filter("XDP", &["xdp"])
.add_filter("XFDF", &["xfdf"])
.add_filter("FDF", &["fdf"])
.add_filter("CSV", &["csv"])
.add_filter(tl!("Text").to_string(), &["txt"])
.add_filter_any_case("XML", &["xml"])
.add_filter_any_case("XDP", &["xdp"])
.add_filter_any_case("XFDF", &["xfdf"])
.add_filter_any_case("FDF", &["fdf"])
.add_filter_any_case("CSV", &["csv"])
.add_filter_any_case(tl!("Text").to_string(), &["txt"])
} else {
d.add_filter("XFDF", &["xfdf"])
.add_filter("FDF", &["fdf"])
.add_filter("XML", &["xml"])
.add_filter("CSV", &["csv"])
.add_filter(tl!("Text").to_string(), &["txt"])
d.add_filter_any_case("XFDF", &["xfdf"])
.add_filter_any_case("FDF", &["fdf"])
.add_filter_any_case("XML", &["xml"])
.add_filter_any_case("CSV", &["csv"])
.add_filter_any_case(tl!("Text").to_string(), &["txt"])
};
let d = crate::editing::in_document_folder(d, folder.as_deref());
self.ask_one(crate::pickers::Ask::Save(d), None, write);
+3 -1
View File
@@ -3,6 +3,8 @@
//! (Size, Poster, Multiple, Booklet); orientation; comments & forms; and a live preview of the
//! sheets. Printing sends the print-ready PDF to the system spooler; "Save as PDF" writes it.
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2};
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
@@ -551,7 +553,7 @@ impl PdfCraftApp {
Some(p) => write(self, p.into()),
None => {
let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF");
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf"));
let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf"));
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, path| {
write(app, path);
})
+109
View File
@@ -459,3 +459,112 @@ trailer << /Root 1 0 R >>
h.run_steps(3);
assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone");
}
/// The active document's outline, flattened like [`outline`].
fn active_outline(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option<usize>, usize)> {
let i = h.state().active.unwrap();
let id = h.state().views[i].id;
fn flat(items: &[pdfcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option<usize>, usize)>) {
for i in items {
out.push((i.title.clone(), i.page, depth));
flat(&i.children, depth + 1, out);
}
}
let mut out = Vec::new();
flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out);
out
}
const ONE_PAGE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() {
let mut h = harness();
add(&mut h, "Intro");
h.state_mut().set_option("page", "3").unwrap();
h.run_steps(3);
add(&mut h, "Results");
menu(&mut h, "Results", "Indent");
let tree = [("Intro".to_string(), Some(0), 0), ("Results".to_string(), Some(2), 1)];
assert_eq!(outline(&h), tree);
// Copy and paste after itself: the whole subtree, as one undo step.
menu(&mut h, "Intro", "Copy");
menu(&mut h, "Intro", "Paste");
assert_eq!(outline(&h), [tree.clone(), tree.clone()].concat());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h), tree, "a paste is one undo step");
// Pasted on a child, it goes in after that child, at the child's level.
menu(&mut h, "Results", "Paste");
let nested = [("Intro".to_string(), Some(0), 1), ("Results".to_string(), Some(2), 2)];
assert_eq!(outline(&h), [tree.clone(), nested].concat());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h), tree);
// Cut takes the subtree away in one step and keeps it to paste.
menu(&mut h, "Intro", "Cut");
assert!(outline(&h).is_empty());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert_eq!(outline(&h), tree, "a cut is one undo step");
// Into a one-page document: Results' page 3 is not there, so it keeps its place without a
// destination rather than pointing at the wrong page, and the toast says so.
h.state_mut().open_bytes("one.pdf", None, ONE_PAGE.to_vec()).unwrap();
h.run_steps(3);
h.get_by_label("Bookmark options").click();
h.run_steps(2);
h.get_by_label("Paste bookmarks").click();
h.run_steps(3);
assert_eq!(active_outline(&h), [("Intro".to_string(), Some(0), 0), ("Results".to_string(), None, 1)]);
let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("1 pasted bookmark points past the last page"), "{toast}");
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(active_outline(&h).is_empty());
assert_eq!(outline(&h), tree, "the source is untouched");
}
/// A one-page document whose single bookmark "Top" heads a chain `depth` bookmarks deep.
fn deep_outline_pdf(depth: usize) -> Vec<u8> {
let mut pdf = String::from(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >> endobj
",
);
for k in 0..depth {
let id = 10 + k;
let parent = if k == 0 { 4 } else { id - 1 };
let title = if k == 0 { "Top".to_string() } else { format!("L{k}") };
let kids = if k + 1 < depth { format!("/First {0} 0 R /Last {0} 0 R /Count 1", id + 1) } else { String::new() };
pdf.push_str(&format!("{id} 0 obj << /Title ({title}) /Parent {parent} 0 R {kids} /Dest [3 0 R /Fit] >> endobj\n"));
}
pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF");
pdf.into_bytes()
}
/// Cutting a subtree nested deeper than a copy can hold would lose what the copy left out, so
/// the cut copies and says why it deleted nothing.
#[test]
fn cutting_a_subtree_too_deep_to_copy_keeps_it() {
let mut h = harness();
h.state_mut().open_bytes("deep.pdf", None, deep_outline_pdf(40)).unwrap();
h.run_steps(3);
let before = active_outline(&h);
assert_eq!(before.first().map(|b| b.0.as_str()), Some("Top"));
menu(&mut h, "Top", "Cut");
assert_eq!(active_outline(&h), before, "nothing was cut");
let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("so nothing was cut"), "{toast}");
assert!(h.state().bookmark_clipboard.is_some(), "the copy is still on the bookmark clipboard");
}
+19 -1
View File
@@ -51,7 +51,8 @@ impl Layouter<'_> {
let som = ctx.som.clone();
// A container without a width is as wide as its content (a radio group beside its
// question, say), and so is a text label or a button (as wide as its caption), within
// what is available; any other field without one takes what is available.
// what is available, and so is a check box (its box and caption); any other field without
// one takes what is available.
let w = match c.w {
Some(w) => w,
None => match node {
@@ -65,6 +66,7 @@ impl Layouter<'_> {
.as_ref()
.and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)))
.map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)),
Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w),
Node::Field(_) => avail_w,
},
};
@@ -149,6 +151,22 @@ impl Layouter<'_> {
}
}
/// How wide check box `f` is without a width of its own (XFA 3.3 Ch.8, growable fields): its
/// box and widget margins, plus its caption beside it (its reserve, else its text), or the
/// wider of the two when the caption is above or below.
fn check_width(&self, f: &Field, ctx: &Ctx) -> f64 {
let check = f.check_size.unwrap_or(10.0) + f.ui_margin.horizontal();
let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else {
return check;
};
// The 2pt matches `caption_reserve`'s room for a caption without a reserve.
let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0);
match cap.placement {
Placement::Top | Placement::Bottom => check.max(text()),
_ => check + cap.reserve.unwrap_or_else(text),
}
}
/// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying
/// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws
/// nothing.
+46
View File
@@ -2997,6 +2997,52 @@ fn a_radio_button_sits_where_a_check_box_would() {
}
}
/// A check box without a width is as wide as its box and caption, at least its minW, not the rest
/// of its group: two choices side by side in a positioned radio group keep their own boxes.
#[test]
fn a_radio_choice_without_a_width_is_as_wide_as_its_content() {
let choice = |name: &str, x: &str, item: &str| {
format!(
r#"<field name="{name}" x="{x}" y="0mm" h="5.906mm" minW="20.32mm"><ui><checkButton shape="round"/></ui><caption reserve="-5.08mm"><value><text>{name}</text></value><para hAlign="center"/></caption><font typeface="Arial" size="10pt"/><items><text>{item}</text></items></field>"#
)
};
let body = format!(
r#"<subform name="body" layout="position" w="7.5in" h="1in"><exclGroup name="pick" x="0mm" y="0mm" w="40.64mm">{}{}</exclGroup></subform>"#,
choice("Yes", "0mm", "Y"),
choice("No", "20.308mm", "N"),
);
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
let laid = widgets(&form.pages[0]);
let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}"));
let mm = 72.0 / 25.4;
// A negative reserve (as Designer writes) sizes the caption to its text, so content is
// narrower than minW and each box sits at the right end of its own 20.32mm-wide choice.
assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes"));
assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No"));
}
/// Without a width or minW, a check box is exactly its caption's reserve plus its box, whether
/// placed (its right-aligned box ends there) or flowed (two fit side by side in an lr-tb row).
#[test]
fn a_check_box_without_a_width_is_its_caption_and_box() {
let check = |name: &str, at: &str| {
format!(
r#"<field name="{name}" {at} h="0.25in"><ui><checkButton size="10pt"/></ui><caption reserve="0.5in"><value><text>{name}</text></value></caption><para hAlign="right"/><items><integer>1</integer><integer>0</integer></items></field>"#
)
};
let box_at = |body: String, name: &str| {
let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
widgets(&form.pages[0]).iter().find(|w| w.name == name).map(|w| (w.rect.x - 36.0, w.rect.y - 36.0)).unwrap()
};
let placed = format!(r#"<subform name="body" layout="position" w="7.5in" h="1in">{}</subform>"#, check("placed", r#"x="0in" y="0in""#));
let (x, _) = box_at(placed, "placed");
assert!((x - 36.0).abs() < 0.01, "the box ends the 46pt field, after the 36pt caption: {x}");
let flowed = format!(r#"<subform name="body" layout="lr-tb" w="3in">{}{}</subform>"#, check("first", ""), check("second", ""));
let (x, y) = box_at(flowed, "second");
assert!((x - 82.0).abs() < 0.01 && y.abs() < 0.01, "the second check box follows the first on its row: ({x}, {y})");
}
/// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than
/// its field shrinking to fit; and a field without a height is as tall as that caption drawn in
/// all the box leaves, not at some narrower width.
+7
View File
@@ -0,0 +1,7 @@
Copyright (c) 2025,2026 Kristof Ralovich
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+38 -2
View File
@@ -3421,6 +3421,30 @@ tools = ["text_styles", "bookmark_from_text_styles"]
evidence = ["crates/engine/src/tests.rs::bookmarks_from_text_styles_nest_the_headings_and_undo", "crates/engine/src/text_style_bookmarks.rs::subset_tags_are_left_out_of_the_font_name", "crates/engine/src/text_style_bookmarks.rs::styles_match_within_a_quarter_point", "crates/automation/tests/it/automation.rs::bookmarks_from_text_styles_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_text_styles_from_the_bookmark_options"]
notes = "Bookmark options (and Pages) ▸ New bookmarks from text styles…, for documents whose headings aren't tagged (#1693): the dialog lists the document's text styles (font without its subset tag, the size shown at, bold, italic, line count and a sample), the largest first and offered for level 1; one style per level, up to four levels (nine headlessly). Every line in a level's style becomes a bookmark of that level under a new first \"Untitled\" bookmark, a heading set on several rows is one bookmark, and each goes to the top of its heading (/XYZ). Lines are read down each page, then left to right, so a multi-column page's headings are taken row by row across the columns; sizes within a quarter point match. Not yet: several styles for one level, colour as part of a style, and the first 5000 pages only. Acrobat has no such command (its bookmarks come from structure); this is beyond it"
[[feature]]
id = "organize.bookmarks-import-export"
title = "Export, import and copy bookmarks"
area = "D"
tier = "P2"
milestone = "M4"
status = "partial"
commands = []
tools = ["bookmark_export", "bookmark_import", "bookmark_copy"]
evidence = ["crates/organize/src/tests.rs::an_exported_outline_imports_into_another_document_unchanged", "crates/organize/src/tests.rs::a_page_the_target_lacks_keeps_its_bookmark_without_a_destination", "crates/organize/src/tests.rs::an_import_appends_inserts_or_replaces", "crates/organize/src/tests.rs::a_bad_node_anywhere_refuses_the_whole_import", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents", "crates/automation/tests/it/automation.rs::a_bad_bookmark_tree_is_refused_whole_and_names_the_node"]
notes = "#6018, headless only. The bookmark tree (or one bookmark's subtree) goes out as JSON with titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space, and comes back unchanged: into another document, under any bookmark, at any position, or in place of its outline, as one undoable step; bookmark_copy does the same between open documents by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported, never dropped. Not yet: a panel command and preview, CSV/Markdown/pdftk formats, remapping by named destination (named destinations travel resolved to their page), actions other than go-to-page, structure links (/SE)"
[[feature]]
id = "organize.bookmark-clipboard"
title = "Cut, copy and paste bookmarks"
area = "D"
tier = "P2"
milestone = "M4"
status = "shipped"
commands = []
tools = ["bookmark_copy", "bookmark_export", "bookmark_import", "bookmark_delete"]
evidence = ["crates/ui-egui/tests/it/bookmarks.rs::cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents"]
notes = "#3994 (the cut/copy/paste part). Cut, Copy and Paste in a bookmark's menu, and Paste bookmarks in Bookmark options, carry a bookmark with its whole subtree within a document or into another one, each as one undo step. A destination is a page index: a bookmark whose page the target lacks keeps its place without a destination and the toast counts them. Headless, bookmark_copy copies between open documents directly (there is no clipboard). Not yet: keyboard shortcuts in the panel, the system clipboard (the bookmarks stay inside the app), several bookmarks at once, copying out of a document that cannot be edited (the bookmark menu is hidden there), page-ordered insertion (the other half of #3994), actions other than go-to-page"
[[feature]]
id = "organize.attachments-add"
title = "Add file attachments"
@@ -5046,8 +5070,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"]
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_kids_length_limit_does_not_bring_back_its_parents", "crates/automation/tests/it/automation.rs::clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"]
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit (clearing it leaves none, not a parent field's), password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
[[feature]]
id = "form.props-actions"
@@ -9902,6 +9926,18 @@ tools = []
evidence = []
notes = ""
[[feature]]
id = "misc.3d-add"
title = "Add a 3D model (U3D/PRC) to a page"
area = "N"
tier = "P3"
milestone = "M12"
status = "partial"
commands = []
tools = ["model_add"]
evidence = ["crates/automation/tests/it/model_3d.rs::model_add_embeds_the_model_with_a_fitted_poster", "crates/automation/tests/it/model_3d.rs::model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click", "crates/automation/tests/it/model_3d.rs::model_add_refuses_what_is_not_a_model_and_adds_nothing", "crates/engine/src/tests.rs::a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused", "crates/annot/src/tests.rs::a_3d_model_raises_an_older_document_to_its_version"]
notes = "Embeds a U3D or PRC file (detected from its header) as a 3D annotation with an activation choice and a poster: a chosen image or PDF page fitted in the box, or a labelled frame. Not yet: a poster rendered from the model, views (/VA, /3DV), replacing the poster later, a UI command. The poster is drawn in user space and not counter-rotated, so on a page with /Rotate it turns with the page, as the model does. An older file's catalog /Version is raised to 1.6 (U3D) or 1.7 (PRC); Adobe's extension level for PRC in 1.7 is not written."
[[feature]]
id = "misc.legacy-multimedia"
title = "Legacy multimedia (screen annotations, renditions)"
+1 -1
View File
File diff suppressed because one or more lines are too long
+235 -101
View File
@@ -1008,15 +1008,16 @@ fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
return None;
}
let (bit_size, chunk_len) = if is_png_predictor {
(
8,
// PdfCraft patch: in usize (the u8 product overflowed).
(usize::from(params.colors) * usize::from(params.bits_per_component)).div_ceil(8),
)
} else {
(params.bits_per_component, params.colors as usize)
};
if is_png_predictor {
// PdfCraft patch: PNG predictors work on bytes whatever the bit depth, so they are
// undone a row at a time on byte slices instead of through the bit-level readers.
// In usize (the u8 product overflowed).
let bpp = (usize::from(params.colors) * usize::from(params.bits_per_component))
.div_ceil(8);
return Some(unfilter_png(&data, row_len, bpp));
}
let (bit_size, chunk_len) = (params.bits_per_component, params.colors as usize);
let zero_row = vec![0; row_len];
let mut prev_row = BitChunks::new(&zero_row, bit_size, chunk_len)?;
let zero_col = BitChunk::new(0, chunk_len);
@@ -1024,58 +1025,7 @@ fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
let mut writer = BitWriter::new(&mut out, bit_size)?;
for in_row in data.chunks_exact(total_row_len) {
if is_png_predictor {
let predictor = in_row[0];
let in_data = &in_row[1..];
let in_data_chunks = BitChunks::new(in_data, bit_size, chunk_len)?;
match predictor {
1 => apply::<Sub>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
2 => apply::<Up>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
3 => apply::<Avg>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
4 => apply::<Paeth>(
prev_row,
zero_col.clone(),
zero_col.clone(),
in_data_chunks,
&mut writer,
chunk_len,
bit_size,
)?,
_ => {
// Just copy the data.
let mut reader = BitReader::new(in_data);
while let Some(data) = reader.read(bit_size) {
writer.write(data);
}
}
}
} else if i == 2 {
if i == 2 {
apply::<Sub>(
prev_row,
zero_col.clone(),
@@ -1104,6 +1054,98 @@ fn apply_predictor(data: Vec<u8>, params: &PredictorParams) -> Option<Vec<u8>> {
}
}
/// PdfCraft patch: undoes PNG row filters (ISO 32000-2 §7.4.4.4, PNG specification §9) in
/// rows of `row_len` bytes, each after its filter-type byte, with `bpp` bytes per complete pixel
/// (at least 1). An unknown filter type copies the row as it is. Bytes after the last complete
/// row are dropped.
fn unfilter_png(data: &[u8], row_len: usize, bpp: usize) -> Vec<u8> {
let bpp = bpp.max(1);
let in_row_len = row_len.saturating_add(1);
let num_rows = data.len() / in_row_len;
let mut out = vec![0; num_rows.saturating_mul(row_len)];
let zero_row = vec![0; row_len];
let mut prev: &[u8] = &zero_row;
for (cur, in_row) in out
.chunks_exact_mut(row_len)
.zip(data.chunks_exact(in_row_len))
{
let Some((&filter, src)) = in_row.split_first() else {
break;
};
match filter {
1 => {
let mut left: &[u8] = &zero_row;
for (px, src) in cur.chunks_mut(bpp).zip(src.chunks(bpp)) {
for ((c, s), l) in px.iter_mut().zip(src).zip(left) {
*c = s.wrapping_add(*l);
}
left = px;
}
}
2 => {
for ((c, s), u) in cur.iter_mut().zip(src).zip(prev) {
*c = s.wrapping_add(*u);
}
}
3 => {
let mut left: &[u8] = &zero_row;
for ((px, src), up) in cur
.chunks_mut(bpp)
.zip(src.chunks(bpp))
.zip(prev.chunks(bpp))
{
for (((c, s), l), u) in px.iter_mut().zip(src).zip(left).zip(up) {
// The sum is taken before halving, without wrapping.
let avg = (u16::from(*l) + u16::from(*u)) / 2;
*c = s.wrapping_add(avg as u8);
}
left = px;
}
}
4 => {
let mut left: &[u8] = &zero_row;
let mut up_left: &[u8] = &zero_row;
for ((px, src), up) in cur
.chunks_mut(bpp)
.zip(src.chunks(bpp))
.zip(prev.chunks(bpp))
{
for ((((c, s), l), u), ul) in
px.iter_mut().zip(src).zip(left).zip(up).zip(up_left)
{
*c = s.wrapping_add(paeth_byte(*l, *u, *ul));
}
left = px;
up_left = up;
}
}
_ => {
for (c, s) in cur.iter_mut().zip(src) {
*c = *s;
}
}
}
prev = cur;
}
out
}
/// The Paeth predictor on bytes: the neighbour nearest `left + up - up_left`, preferring left,
/// then up.
fn paeth_byte(left: u8, up: u8, up_left: u8) -> u8 {
let (a, b, c) = (i16::from(left), i16::from(up), i16::from(up_left));
let pa = (b - c).abs();
let pb = (a - c).abs();
let pc = (a + b - c - c).abs();
let not_left = if pb <= pc { up } else { up_left };
if pa <= pb && pa <= pc { left } else { not_left }
}
fn apply<'a, T: Predictor>(
prev_row: BitChunks<'a>,
mut prev_col: BitChunk,
@@ -1153,46 +1195,6 @@ impl Predictor for Sub {
}
}
struct Up;
impl Predictor for Up {
fn predict(cur_row: u16, prev_row: u16, _: u16, _: u16) -> u16 {
cur_row.wrapping_add(prev_row)
}
}
struct Avg;
impl Predictor for Avg {
fn predict(cur_row: u16, prev_row: u16, prev_col: u16, _: u16) -> u16 {
cur_row.wrapping_add(((prev_col as u32 + prev_row as u32) / 2) as u16)
}
}
struct Paeth;
impl Predictor for Paeth {
fn predict(cur_row: u16, prev_row: u16, prev_col: u16, top_left: u16) -> u16 {
fn paeth(a: u16, b: u16, c: u16) -> u16 {
let a = a as i32;
let b = b as i32;
let c = c as i32;
let p = a + b - c;
let pa = (p - a).abs();
let pb = (p - b).abs();
let pc = (p - c).abs();
if pa <= pb && pa <= pc {
a as u16
} else if pb <= pc {
b as u16
} else {
c as u16
}
}
cur_row.wrapping_add(paeth(prev_col, prev_row, top_left))
}
}
#[cfg(test)]
#[rustfmt::skip]
mod tests {
@@ -1357,3 +1359,135 @@ mod tests {
);
}
}
/// PdfCraft patch tests: PNG predictors are undone byte by byte (`unfilter_png`).
#[cfg(test)]
mod pdfcraft_png_predictor_tests {
use crate::filter::lzw_flate::{PredictorParams, apply_predictor};
/// PNG unfiltering written out from the PNG specification (§9.2–9.4), one byte at a time.
fn reference(data: &[u8], row_len: usize, bpp: usize) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
for (r, row) in data.chunks_exact(row_len + 1).enumerate() {
let start = r * row_len;
for x in 0..row_len {
let left = if x >= bpp { out[start + x - bpp] } else { 0 };
let up = if r > 0 { out[start + x - row_len] } else { 0 };
let up_left = if r > 0 && x >= bpp {
out[start + x - row_len - bpp]
} else {
0
};
let predicted = match row[0] {
1 => left,
2 => up,
3 => ((u16::from(left) + u16::from(up)) / 2) as u8,
4 => {
let p = i16::from(left) + i16::from(up) - i16::from(up_left);
let (pa, pb, pc) = (
(p - i16::from(left)).abs(),
(p - i16::from(up)).abs(),
(p - i16::from(up_left)).abs(),
);
if pa <= pb && pa <= pc {
left
} else if pb <= pc {
up
} else {
up_left
}
}
_ => 0,
};
out.push(row[1 + x].wrapping_add(predicted));
}
}
out
}
#[test]
fn png_predictors_match_the_specification_at_every_depth_and_pixel_size() {
let mut seed: u32 = 0x2545_f491;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for case in 0..400 {
let colors = 1 + (next() % 4) as u8;
let bits_per_component = [1u8, 2, 4, 8, 16][(next() % 5) as usize];
let columns = 1 + (next() % 40) as usize;
let bpp = (usize::from(colors) * usize::from(bits_per_component)).div_ceil(8);
let row_len =
(columns * usize::from(colors) * usize::from(bits_per_component)).div_ceil(8);
let rows = 1 + (next() % 6) as usize;
let mut data = Vec::new();
for _ in 0..rows {
// Filter types 0–4, and 5–7, which are not PNG filters and copy the row.
data.push((next() % 8) as u8);
data.extend((0..row_len).map(|_| next() as u8));
}
// A partial row at the end is not decoded.
let tail = (next() % 3) as usize;
data.extend((0..tail.min(row_len)).map(|_| next() as u8));
let params = PredictorParams {
predictor: 10 + (next() % 6) as u8,
colors,
bits_per_component,
columns,
early_change: false,
};
let got = apply_predictor(data.clone(), &params).unwrap();
assert_eq!(
got,
reference(&data, row_len, bpp),
"case {case}: {colors} colours at {bits_per_component} bits, {columns} columns"
);
}
}
#[test]
fn a_row_ending_part_way_through_a_pixel_keeps_its_last_byte() {
// Three 4-bit samples are a byte and a half, so a three-column row is five bytes and ends
// half-way through a two-byte pixel. Each row is filtered Up.
let data = [
2, 10, 11, 12, 13, 14, //
2, 20, 21, 22, 23, 24, //
2, 30, 31, 32, 33, 34,
];
let params = PredictorParams {
predictor: 15,
colors: 3,
bits_per_component: 4,
columns: 3,
early_change: false,
};
assert_eq!(
apply_predictor(data.to_vec(), &params).unwrap(),
[10, 11, 12, 13, 14, 30, 32, 34, 36, 38, 60, 63, 66, 69, 72]
);
}
#[test]
fn png_average_and_paeth_carry_the_left_pixel_across_a_wide_row() {
// Three 16-bit samples (6 bytes) per pixel: the left neighbour is six bytes back.
let row_len = 6 * 3;
let mut data = vec![3u8];
data.extend((0..row_len).map(|i| (i * 37 + 200) as u8));
data.push(4);
data.extend((0..row_len).map(|i| (255 - i * 11) as u8));
let params = PredictorParams {
predictor: 15,
colors: 3,
bits_per_component: 16,
columns: 3,
early_change: false,
};
assert_eq!(
apply_predictor(data.clone(), &params).unwrap(),
reference(&data, row_len, 6)
);
}
}
+1
View File
@@ -34,6 +34,7 @@ pub const TABLE: &[(&str, Class)] = &[
("filters", Class::Standalone(0)),
("crypt", Class::Standalone(0)),
("arlington", Class::Standalone(0)),
("prc", Class::Standalone(0)),
// L1 object layer
("cos", Class::Standalone(1)),
// L2 core model