refactor(edit): drop test-only image API and dead graphics-state code

PR #2155 review item A5 (part 1). No behaviour change for callers:
- crop_image, and the arrange_image/crop_image/remove_image_crop/MaskIdentity
  re-exports, existed only for tests; tests now go through change_image.
- PageImage.smask was read only by tests; page_images no longer tracks
  ExtGState for it. The nested-mask test keeps its rendered-pixel check.
- MaskIdentity's Display, ExtractedScopeState.effective_cm, the
  ExtendedGStateTracker overflow_depth (an error ends the scan, so it was
  never read) and the EffectiveGState one-line getters were unused.
- merge_dict always gets the ExtGState name, so the hash fallback for an
  unnamed inline soft mask was unreachable outside a test.
- cid_to_gid_map_odd_length_is_trimmed_to_even exercised no crate code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FredAppsandClaude Opus 5.5 committed 2026-11-01 12:33:50 +01:00
1 parent a910b8cca2
commit 71b39aec5b
3 files changed
+94 -158

No files matched your search

+15 -70
View File
@@ -13,7 +13,7 @@ use crate::text::content_streams;
/// The identity of a soft mask (`/SMask`) in the PDF graphics state.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MaskIdentity {
pub(crate) enum MaskIdentity {
/// Indirect reference to a mask dictionary or stream.
Ref(u32, u16),
/// Indirect reference to the transparency group (`/G`) in a mask dictionary.
@@ -22,16 +22,6 @@ pub enum MaskIdentity {
Named(Vec<u8>),
}
impl std::fmt::Display for MaskIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MaskIdentity::Ref(num, generation) => write!(f, "{num} {generation} R"),
MaskIdentity::Group(num, generation) => write!(f, "group {num} {generation} R"),
MaskIdentity::Named(name) => write!(f, "{}", String::from_utf8_lossy(name)),
}
}
}
/// An image drawn on a page.
#[derive(Clone, Debug, PartialEq)]
pub struct PageImage {
@@ -52,8 +42,6 @@ pub struct PageImage {
/// Pixel size; zero for vector/grouped Form artwork.
pub width: u32,
pub height: u32,
/// Active soft mask identity at the image placement, if any.
pub smask: Option<MaskIdentity>,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
pub stream: usize,
@@ -276,16 +264,12 @@ impl ImageState {
pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
let p = page_of(doc, page)?;
let xo = xobjects(doc, &p.dict);
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
// The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut tracker = ExtendedGStateTracker::default();
let pieces = joined(&content_streams(doc, &p.dict)?);
for (i, op) in pieces.parse().ops.iter().enumerate() {
state.apply(op)?;
tracker.step(op, &ext_gstates, doc, state.placement.matrix)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
@@ -345,7 +329,6 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
smask: tracker.current_state.smask.clone(),
stream: pieces.piece_of(op.span.start),
op: i,
draw_matrix: ctm.0,
@@ -422,7 +405,6 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
smask: None,
stream: stream(op),
op: i,
draw_matrix: ctm.0,
@@ -684,7 +666,6 @@ pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> V
pub(crate) struct ExtractedScopeState {
pub clip_ops: Vec<Op>,
pub state_ops: Vec<Op>,
pub effective_cm: Matrix,
pub do_op: Op,
}
@@ -697,30 +678,16 @@ pub(crate) struct EffectiveGState {
}
impl EffectiveGState {
pub(crate) fn effective_ca(&self) -> f64 {
self.ca.unwrap_or(1.0)
}
pub(crate) fn effective_ca_stroke(&self) -> f64 {
self.ca_stroke.unwrap_or(1.0)
}
pub(crate) fn effective_bm(&self) -> &[u8] {
self.bm.as_deref().unwrap_or(b"Normal")
}
pub(crate) fn effective_smask(&self) -> Option<&MaskIdentity> {
self.smask.as_ref()
}
pub(crate) fn visually_differs(&self, other: &EffectiveGState) -> bool {
(self.effective_ca() - other.effective_ca()).abs() > 1e-4
|| (self.effective_ca_stroke() - other.effective_ca_stroke()).abs() > 1e-4
|| self.effective_bm() != other.effective_bm()
|| self.effective_smask() != other.effective_smask()
(self.ca.unwrap_or(1.0) - other.ca.unwrap_or(1.0)).abs() > 1e-4
|| (self.ca_stroke.unwrap_or(1.0) - other.ca_stroke.unwrap_or(1.0)).abs() > 1e-4
|| self.bm.as_deref().unwrap_or(b"Normal") != other.bm.as_deref().unwrap_or(b"Normal")
|| self.smask != other.smask
}
pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, gs_name: Option<&[u8]>) {
/// Merge the `gs_name` ExtGState `dict`. An inline soft mask with no group of its own is
/// known by the name of the ExtGState that sets it.
pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, gs_name: &[u8]) {
if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) {
self.ca = Some(val);
}
@@ -751,18 +718,8 @@ impl EffectiveGState {
self.smask = Some(MaskIdentity::Ref(r.num, r.generation));
} else if let Some(name) = mask_dict.get(b"Name").map(|n| doc.resolve(n)).and_then(|n| n.as_name().map(|s| s.to_vec())) {
self.smask = Some(MaskIdentity::Named(name));
} else if let Some(name) = gs_name {
self.smask = Some(MaskIdentity::Named(name.to_vec()));
} else {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for (k, v) in mask_dict.iter() {
k.as_slice().hash(&mut hasher);
format!("{v:?}").hash(&mut hasher);
}
let id = format!("InlineMask_{:016x}", hasher.finish());
self.smask = Some(MaskIdentity::Named(id.into_bytes()));
self.smask = Some(MaskIdentity::Named(gs_name.to_vec()));
}
} else if let Some(r) = raw_smask.as_ref() {
self.smask = Some(MaskIdentity::Ref(r.num, r.generation));
@@ -781,36 +738,29 @@ struct ExtendedGStateTracker {
stack: Vec<(EffectiveGState, ActiveGsOps)>,
current_state: EffectiveGState,
active_gs_ops: ActiveGsOps,
overflow_depth: usize,
}
impl ExtendedGStateTracker {
fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document, ctm: Matrix) -> Result<(), EditError> {
match op.op.as_slice() {
b"q" => {
if self.stack.len() >= 4096 || self.overflow_depth > 0 {
self.overflow_depth = self.overflow_depth.saturating_add(1);
if self.stack.len() >= 4096 {
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
}
self.stack.push((self.current_state.clone(), std::rc::Rc::clone(&self.active_gs_ops)));
}
b"Q" => {
if self.overflow_depth > 0 {
self.overflow_depth = self.overflow_depth.saturating_sub(1);
} else if let Some((prev_state, prev_ops)) = self.stack.pop() {
if let Some((prev_state, prev_ops)) = self.stack.pop() {
self.current_state = prev_state;
self.active_gs_ops = prev_ops;
}
}
b"gs" => {
if self.overflow_depth > 0 {
return Ok(());
}
let valid_gs = op
.name(0)
.and_then(|name| ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()).map(|dict| (name, dict)));
if let Some((name, dict)) = valid_gs {
self.current_state.merge_dict(&dict, doc, Some(name));
self.current_state.merge_dict(&dict, doc, name);
let mut next = (*self.active_gs_ops).clone();
if let Some(pos) = next.iter().position(|(k, _, _)| k.as_slice() == name) {
if let Some(entry) = next.get_mut(pos) {
@@ -1087,7 +1037,7 @@ pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initi
}
let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
if start == target {
return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), effective_cm: initial_ctm, do_op: target_op }));
return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), do_op: target_op }));
}
let mut clip_ops = Vec::new();
let mut state_ops = Vec::new();
@@ -1178,7 +1128,7 @@ pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initi
return Ok(None);
}
Ok(Some(ExtractedScopeState { clip_ops, state_ops, effective_cm: matrix, do_op: target_op }))
Ok(Some(ExtractedScopeState { clip_ops, state_ops, do_op: target_op }))
}
/// Change the stacking order of image `index` on `page`.
@@ -1549,7 +1499,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
if let Some(name) = op.name(0)
&& let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned())
{
reconstructed_state.merge_dict(&dict, doc, Some(name));
reconstructed_state.merge_dict(&dict, doc, name);
}
}
@@ -1741,11 +1691,6 @@ pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Resul
Ok(())
}
/// Crop image `index` on `page` to `crop_rect` in user space `[x0, y0, x1, y1]`.
pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64; 4]) -> Result<(), EditError> {
change_image(doc, page, index, &ImageChange::Crop(crop_rect))
}
/// Change image `index` (from [`page_images`]) on `page`.
pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> {
let images = page_images(doc, page)?;
+1 -4
View File
@@ -43,10 +43,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{
ArrangeOrder, ImageChange, MaskIdentity, PageImage, arrange_image, change_image, crop_image, page_images, reading_images, rect_to_rect,
remove_image_crop, turn_about_centre, turn_by_degrees,
};
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
mod linebreak;
+78 -84
View File
@@ -3516,6 +3516,19 @@ fn image_page() -> Document {
image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET")
}
/// Arrange, Crop and Remove crop as the engine asks for them, through [`images::change_image`].
fn arrange(doc: &mut Document, page: usize, index: usize, order: images::ArrangeOrder) -> Result<(), EditError> {
images::change_image(doc, page, index, &images::ImageChange::Arrange(order))
}
fn crop(doc: &mut Document, page: usize, index: usize, keep: [f64; 4]) -> Result<(), EditError> {
images::change_image(doc, page, index, &images::ImageChange::Crop(keep))
}
fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
images::change_image(doc, page, index, &images::ImageChange::RemoveCrop)
}
fn image_page_with(content: &str) -> Document {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
@@ -3789,14 +3802,14 @@ fn image_arrange_stacking_order_and_remove_crop() {
assert!(close(initial_images[1].rect, [200.0, 200.0, 350.0, 350.0]));
// Boundary case: image 0 is already at the back; SendToBack and SendBackward report an error.
assert!(images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err());
assert!(images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err());
assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err());
assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err());
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!(imgs.len(), 2);
assert!(close(imgs[0].rect, initial_images[0].rect));
// Bring forward image 0: moves it past the middle text, but it is still behind image 1.
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap();
let doc = reopen(&doc);
let bytes = page_content_bytes(&doc, 0);
let str_content = String::from_utf8_lossy(&bytes);
@@ -3807,7 +3820,7 @@ fn image_arrange_stacking_order_and_remove_crop() {
// Bring to front on image 0: moves it past image 1 to the front!
let mut doc = doc;
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let imgs_after_front = images::page_images(&doc, 0).unwrap();
assert_eq!(imgs_after_front.len(), 2);
@@ -3817,7 +3830,7 @@ fn image_arrange_stacking_order_and_remove_crop() {
// Send to back on the front image (index 1) restores it to the back.
let mut doc = doc;
images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap();
arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap();
let imgs_after_back = images::page_images(&doc, 0).unwrap();
assert!(close(imgs_after_back[0].rect, [100.0, 100.0, 200.0, 200.0]));
assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0]));
@@ -3827,26 +3840,26 @@ fn image_arrange_stacking_order_and_remove_crop() {
let mut doc = image_page_with(cropped_content);
let img_before = &images::page_images(&doc, 0).unwrap()[0];
assert_ne!(img_before.visible_rect, Some(img_before.rect));
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let img_after = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_after.visible_rect, Some(img_after.rect));
// Removing crop again when there is no crop is refused.
let mut doc = doc;
assert!(images::remove_image_crop(&mut doc, 0, 0).is_err());
assert!(remove_crop(&mut doc, 0, 0).is_err());
// Test crop_image
// Test crop
let uncropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q";
let mut doc = image_page_with(uncropped_content);
let img_before = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_before.visible_rect, Some(img_before.rect));
images::crop_image(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap();
crop(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap();
let doc = reopen(&doc);
let img_cropped = &images::page_images(&doc, 0).unwrap()[0];
assert!(close(img_cropped.visible_rect.unwrap(), [120.0, 110.0, 250.0, 180.0]));
// And remove_image_crop works on it:
// And remove_crop works on it:
let mut doc = doc;
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let img_restored = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_restored.visible_rect, Some(img_restored.rect));
@@ -3859,20 +3872,20 @@ fn image_arrange_stacking_order_and_remove_crop() {
let img_moved = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]);
let mut doc = doc;
images::crop_image(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap();
crop(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap();
let doc = reopen(&doc);
let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0];
assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted");
assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0]));
// Adjust crop (re-crop)
let mut doc = doc;
images::crop_image(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap();
crop(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap();
let doc = reopen(&doc);
let img_recropped = &images::page_images(&doc, 0).unwrap()[0];
assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0]));
// Remove crop from moved image
let mut doc = doc;
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect));
@@ -3887,7 +3900,7 @@ fn crop_rotated_image_preserves_angle_and_in_place_geometry() {
assert!((m_before[1] - 100.0).abs() < 1e-3);
assert!((m_before[2] - (-50.0)).abs() < 1e-3);
images::crop_image(&mut doc, 0, 0, [140.0, 170.0, 180.0, 210.0]).unwrap();
crop(&mut doc, 0, 0, [140.0, 170.0, 180.0, 210.0]).unwrap();
let doc = reopen(&doc);
let img_cropped = &images::page_images(&doc, 0).unwrap()[0];
@@ -3908,7 +3921,7 @@ fn crop_rotated_image_preserves_angle_and_in_place_geometry() {
assert!(str_content.contains("/Im0 Do"), "{str_content}");
let mut doc = doc;
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let img_restored = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_restored.matrix, m_before);
@@ -3922,7 +3935,7 @@ fn image_arrange_preserves_shared_group_drawings() {
assert_eq!(initial.len(), 2, "both images initially present in shared group");
// Bring image 0 to front: it must not delete image 1 that shares the same q...Q group!
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert_eq!(after.len(), 2, "both images must be preserved after BringToFront");
@@ -3945,7 +3958,7 @@ fn marked_sequence(content: &str, mcid: u32) -> &str {
fn image_arrange_keeps_marked_content_sequences_whole() {
let content = "/P <</MCID 0>> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <</MCID 1>> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC";
let mut doc = image_page_with(content);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let bytes = page_content_bytes(&doc, 0);
let s = String::from_utf8_lossy(&bytes);
@@ -3961,7 +3974,7 @@ fn image_arrange_keeps_marked_content_sequences_whole() {
fn image_arrange_moves_a_bmc_sequence_as_a_whole() {
let content = "/Artifact BMC q 100 0 0 100 50 50 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET";
let mut doc = image_page_with(content);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let s = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
let (art, emc, body, img) = (s.find("/Artifact BMC").unwrap(), s.rfind("EMC").unwrap(), s.find("(Body)").unwrap(), s.find("/Im0 Do").unwrap());
@@ -3975,7 +3988,7 @@ fn arrange_image_in_shared_tagged_block_refuses_or_keeps_tagged() {
let content =
"/Figure <</MCID 0>> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET";
let mut doc = image_page_with(content);
let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
match res {
Err(_) => {
// Refusing is the expected safe behavior when moving out of a shared tagged unit.
@@ -4005,7 +4018,7 @@ fn image_arrange_splits_a_shared_marked_sequence_into_own_tag() {
let content =
"/Figure <</MCID 0>> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET";
let mut doc = image_page_with(content);
assert!(images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err());
assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err());
}
/// Send backward also refuses to rearrange an image when it shares its sequence with other content.
@@ -4014,14 +4027,14 @@ fn image_arrange_send_backward_splits_shared_marked_sequence() {
let content =
"BT /F1 12 Tf 72 700 Td (Header) Tj ET /Table <</MCID 0>> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Cell) Tj ET EMC";
let mut doc = image_page_with(content);
assert!(images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err());
assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err());
}
#[test]
fn arrange_preserves_color_leaking_out_of_marked_content() {
let content = "/Figure <</MCID 0>> BDC 1 0 0 rg q 100 0 0 100 0 0 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Text) Tj ET";
let mut doc = image_page_with(content);
let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
if res.is_ok() {
let bytes = page_content_bytes(&doc, 0);
let s = String::from_utf8_lossy(&bytes);
@@ -4034,7 +4047,7 @@ fn arrange_preserves_color_leaking_out_of_marked_content() {
fn arrange_shared_tagged_does_not_duplicate_mcid() {
let content = "/Figure <</MCID 0>> BDC q 100 0 0 100 0 0 cm /Im0 Do Q 0 0 m 10 10 l S EMC";
let mut doc = image_page_with(content);
assert!(images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err());
assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err());
}
#[test]
@@ -4047,7 +4060,7 @@ fn image_crop_preserves_outer_cm_for_subsequent_drawings() {
assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]);
// Crop image 0
images::crop_image(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap();
crop(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap();
let doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert_eq!(after.len(), 2, "both images must remain");
@@ -4067,7 +4080,7 @@ fn image_arrange_preserves_position_under_active_ctm() {
assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]);
// Move first image to front: must not apply destination CTM twice!
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: check order of operators in the content stream
@@ -4099,7 +4112,7 @@ fn image_arrange_preserves_shared_group_clipping_bounds() {
assert_eq!(initial[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]));
// Bring image 0 to front: moves it past the external text while preserving clipping bounds
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: moved image Do now follows External Text in AST
@@ -4128,7 +4141,7 @@ fn image_arrange_preserves_extended_graphics_state_opacity() {
assert_eq!(initial.len(), 1);
assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: /Im0 Do moved past External Text
@@ -4157,7 +4170,7 @@ fn image_arrange_preserves_exact_non_rectangular_clipping_path() {
let initial = images::page_images(&doc, 0).unwrap();
assert_eq!(initial.len(), 2);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: moved image follows External Text in AST
@@ -4191,13 +4204,13 @@ fn image_remove_crop_after_arrange_keeps_position_under_outer_transform() {
let mut doc = image_page_with(content);
assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [50.0, 50.0, 150.0, 150.0]);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let mut doc = reopen(&doc);
let moved = images::page_images(&doc, 0).unwrap();
assert_eq!(moved[0].rect, [50.0, 50.0, 150.0, 150.0]);
assert_eq!(moved[0].visible_rect, Some([50.0, 50.0, 100.0, 100.0]));
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert_eq!(after.len(), 1);
@@ -4215,7 +4228,7 @@ fn image_arrange_refuses_to_move_past_a_clip_it_is_not_under() {
assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0]));
for order in [images::ArrangeOrder::BringToFront, images::ArrangeOrder::BringForward] {
let err = images::arrange_image(&mut doc, 0, 0, order).unwrap_err();
let err = arrange(&mut doc, 0, 0, order).unwrap_err();
assert!(err.to_string().contains("clipping path"), "{err}");
}
assert_eq!(page_content_bytes(&doc, 0), before, "nothing changes");
@@ -4227,7 +4240,7 @@ fn image_arrange_send_to_back_keeps_a_shared_clip() {
// is no longer under that clip, so the moved unit has to draw it itself.
let content = "BT /F1 12 Tf 72 700 Td (External Text) Tj ET 0 0 60 60 re W n q 100 0 0 100 50 50 cm /Im0 Do Q";
let mut doc = image_page_with(content);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap();
let doc = reopen(&doc);
let bytes = page_content_bytes(&doc, 0);
let str_content = String::from_utf8_lossy(&bytes);
@@ -4251,7 +4264,7 @@ fn unwrapped_image_moves_successfully() {
assert!(init_do < init_text, "/Im0 Do initially precedes Middle Text");
// Move unwrapped image to front (past Middle Text)
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: /Im0 Do now follows Middle Text in the content stream AST
@@ -4298,7 +4311,6 @@ fn test_arrange_unwrapped_image_extraction() {
assert_eq!(extracted.do_op.op.as_slice(), b"Do");
assert_eq!(extracted.do_op.name(0), Some(b"Im0".as_slice()));
assert_eq!(extracted.effective_cm, pdfcraft_content::Matrix([100.0, 0.0, 0.0, 100.0, 100.0, 100.0]));
}
#[test]
@@ -4310,7 +4322,7 @@ fn arrange_unwrapped_image_in_mixed_scope_succeeds() {
let initial = images::page_images(&doc, 0).unwrap();
assert_eq!(initial.len(), 1);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let bytes = page_content_bytes(&doc, 0);
@@ -4336,7 +4348,7 @@ fn image_arrange_with_transformed_clipping_path_succeeds() {
let rect_before = before[0].rect;
let clip_before = before[0].visible_rect;
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert_eq!(after.len(), 1);
@@ -4367,7 +4379,7 @@ fn inherited_source_opacity_is_preserved() {
assert!(init_do < init_text, "/Im0 Do initially precedes External Text");
// Move image to front (past External Text, outside its original outer q...Q scope)
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: /Im0 Do now follows External Text in the AST
@@ -4397,7 +4409,7 @@ fn destination_opacity_pollution_is_prevented() {
let initial_bytes = page_content_bytes(&doc, 0);
// Attempting to move opaque image 0 to front into active transparency scope must safely bail out with Err
let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
assert!(res.is_err(), "must return an explicit Err rather than silently corrupting opacity");
let err_msg = res.unwrap_err().to_string();
assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}");
@@ -4417,7 +4429,7 @@ fn sequential_gs_operators_merging_properties_are_preserved() {
assert_eq!(initial.len(), 1);
assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
// 1. Position: /Im0 Do now follows External Text in the AST
@@ -4451,7 +4463,7 @@ fn destination_differing_blend_mode_pollution_is_prevented() {
let initial_bytes = page_content_bytes(&doc, 0);
// Attempting to move Normal-blend image to front into active Multiply-blend scope must safely bail out with Err
let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
assert!(res.is_err(), "must return an explicit Err rather than silently altering blend mode");
let err_msg = res.unwrap_err().to_string();
assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}");
@@ -4496,9 +4508,8 @@ fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() {
let mut doc = nested_soft_mask_page(content);
let initial = images::page_images(&doc, 0).unwrap();
assert_eq!(initial.len(), 1);
assert_eq!(initial[0].smask, Some(images::MaskIdentity::Group(10, 0)));
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let content_bytes = page_content_bytes(&doc, 0);
let str_content = String::from_utf8_lossy(&content_bytes);
@@ -4512,11 +4523,8 @@ fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() {
assert!(after_a < after_b, "Order: outer /GSA gs must be replayed before inner /GSB gs");
assert!(after_b < after_do, "Order: inner /GSB gs must immediately precede /Im0 Do");
// 2. Assert effective mask upon saving and reopening is correctly identified as B
let reopened_doc = reopen(&doc);
let reopened_images = images::page_images(&reopened_doc, 0).unwrap();
assert_eq!(reopened_images.len(), 1);
assert_eq!(reopened_images[0].smask, Some(images::MaskIdentity::Group(10, 0)));
// 2. The image survives saving and reopening.
assert_eq!(images::page_images(&reopen(&doc), 0).unwrap().len(), 1);
// 3. Rendered-pixel assertion: render output and verify Mask B (left 50x100) is effective, not Mask A (bottom 100x50)
let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
@@ -4542,7 +4550,7 @@ fn destination_differing_mask_pollution_is_prevented() {
let mut doc = nested_soft_mask_page(content);
let initial_bytes = page_content_bytes(&doc, 0);
let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
assert!(res.is_err(), "must return an explicit Err rather than corrupting mask state");
let err_msg = res.unwrap_err().to_string();
assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}");
@@ -4605,7 +4613,7 @@ fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() {
};
// Arrange image: BringToFront
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let content_bytes = page_content_bytes(&doc, 0);
let str_content = String::from_utf8_lossy(&content_bytes);
@@ -5143,12 +5151,12 @@ fn arrange_and_crop_image_in_second_content_stream_succeeds() {
assert_eq!(before[1].name, "Im1");
// Arrange second image (located in the second content stream)
images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap();
arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap();
let after_arrange = images::page_images(&reopen(&doc), 0).unwrap();
assert_eq!(after_arrange.len(), 2);
// Crop image in second stream
images::crop_image(&mut doc, 0, 0, [210.0, 210.0, 290.0, 290.0]).unwrap();
crop(&mut doc, 0, 0, [210.0, 210.0, 290.0, 290.0]).unwrap();
let after_crop = images::page_images(&reopen(&doc), 0).unwrap();
assert_eq!(after_crop.len(), 2);
assert_eq!(after_crop[0].name, "Im1");
@@ -8517,7 +8525,7 @@ mod proptests {
_ => images::ArrangeOrder::SendBackward,
};
let mut doc = image_page_with(&String::from_utf8_lossy(&bytes));
let _ = images::arrange_image(&mut doc, 0, index, order);
let _ = arrange(&mut doc, 0, index, order);
}
#[test]
@@ -8529,8 +8537,8 @@ mod proptests {
bytes in proptest::collection::vec(any::<u8>(), 0..1024),
) {
let mut doc = image_page_with(&String::from_utf8_lossy(&bytes));
let _ = images::crop_image(&mut doc, 0, 0, [x1, y1, x2, y2]);
let _ = images::remove_image_crop(&mut doc, 0, 0);
let _ = crop(&mut doc, 0, 0, [x1, y1, x2, y2]);
let _ = remove_crop(&mut doc, 0, 0);
}
}
}
@@ -8573,7 +8581,7 @@ fn regression_rects(doc: &Document) -> Vec<[i64; 4]> {
fn arrange_keeps_cm_leaking_out_of_marked_content() {
let mut doc = regression_page("/Figure <</MCID 0>> BDC 1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q EMC q 10 0 0 10 200 200 cm /Im0 Do Q");
let before = regression_rects(&doc);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
assert_eq!(before, regression_rects(&reopen(&doc)), "other content moved");
}
@@ -8581,7 +8589,7 @@ fn arrange_keeps_cm_leaking_out_of_marked_content() {
#[test]
fn crop_form_xobject_keeps_requested_area() {
let mut doc = regression_page("q 1 0 0 1 100 100 cm /Fm0 Do Q");
if images::crop_image(&mut doc, 0, 0, [100.0, 100.0, 200.0, 200.0]).is_err() {
if crop(&mut doc, 0, 0, [100.0, 100.0, 200.0, 200.0]).is_err() {
return; // refusing to crop vector art is also acceptable
}
let after = images::page_images(&reopen(&doc), 0).unwrap()[0].clone();
@@ -8593,7 +8601,7 @@ fn crop_form_xobject_keeps_requested_area() {
#[test]
fn arrange_tolerates_missing_extgstate() {
let mut doc = regression_page("q /GSmissing gs 100 0 0 100 50 50 cm /Im0 Do Q /GSone gs q 10 0 0 10 200 200 cm /Im0 Do Q");
let r = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
let r = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront);
assert!(r.is_ok(), "{r:?}");
}
@@ -8602,7 +8610,7 @@ fn arrange_tolerates_missing_extgstate() {
fn noop_arrange_is_not_ok_without_change() {
let mut doc = regression_page("q 10 0 0 10 200 200 cm /Im0 Do Q q 100 0 0 100 50 50 cm /Im0 Do Q");
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let r = images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::BringToFront);
let r = arrange(&mut doc, 0, 1, images::ArrangeOrder::BringToFront);
let after = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(r.is_err() || before != after, "Ok with no change");
}
@@ -8613,7 +8621,7 @@ fn noop_arrange_is_not_ok_without_change() {
fn crop_turned_image_preserves_requested_area() {
let mut doc = regression_page("q 70.71 70.71 -70.71 70.71 300 100 cm /Im0 Do Q");
let req = [270.0, 150.0, 330.0, 200.0];
images::crop_image(&mut doc, 0, 0, req).expect("crop succeeds");
crop(&mut doc, 0, 0, req).expect("crop succeeds");
let after = &images::page_images(&reopen(&doc), 0).unwrap()[0];
let v = after.visible_rect.expect("visible rect exists");
assert!((v[0] - req[0]).abs() < 1.0, "x0: expected {}, got {}", req[0], v[0]);
@@ -8894,7 +8902,7 @@ fn arrange_preserves_clip_leaked_out_of_marked_unit() {
assert!(initial[0].visible_rect.is_some());
assert!(initial[1].visible_rect.is_none(), "second image is fully clipped initially");
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert_eq!(after.len(), 2);
@@ -8905,7 +8913,7 @@ fn arrange_preserves_clip_leaked_out_of_marked_unit() {
let content_cm_clip =
"/Figure <</MCID 0>> BDC 2 0 0 2 0 0 cm 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q";
let mut doc3 = image_page_with(content_cm_clip);
let res3 = images::arrange_image(&mut doc3, 0, 0, images::ArrangeOrder::BringToFront);
let res3 = arrange(&mut doc3, 0, 0, images::ArrangeOrder::BringToFront);
assert!(res3.is_err(), "moving marked unit with both leaking cm and leaking clip must be refused");
}
@@ -8913,7 +8921,7 @@ fn arrange_preserves_clip_leaked_out_of_marked_unit() {
fn arrange_preserves_leaked_color_and_text_state() {
let content = "/Figure <</MCID 0>> BDC /DeviceRGB cs 1 0 0 sc /F1 14 Tf 2 Tc q 10 0 0 10 50 50 cm /Im0 Do Q EMC 0 0 10 10 re f q 10 0 0 10 200 200 cm /Im0 Do Q";
let mut doc = image_page_with(content);
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let doc = reopen(&doc);
let bytes = page_content_bytes(&doc, 0);
let s = String::from_utf8_lossy(&bytes);
@@ -8951,7 +8959,7 @@ fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() {
assert_eq!(page_before.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil starts red");
// Bring to front
images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
// Save and reopen
let doc = reopen(&doc);
@@ -8988,7 +8996,7 @@ fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() {
let form_pixel_idx = (((form_page_before.height - 75) * form_page_before.width + 75) * 4) as usize;
assert_eq!(form_page_before.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form starts green");
images::arrange_image(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
arrange(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap();
let form_doc = reopen(&form_doc);
let form_pdf_after = write_incremental(&form_doc, &SaveOptions::default()).unwrap();
@@ -9751,7 +9759,7 @@ fn image_exclusive_scope_resets_path_on_scope_exit() {
let mut doc = image_page_with(content);
let before = images::page_images(&doc, 0).unwrap();
assert_eq!(before.len(), 1);
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
}
#[test]
@@ -9778,28 +9786,14 @@ fn different_inline_soft_masks_have_distinct_identities() {
dict2.set(b"SMask".to_vec(), Object::Dict(mask2));
let mut state1 = images::EffectiveGState::default();
state1.merge_dict(&dict1, &doc, None);
state1.merge_dict(&dict1, &doc, b"GS1");
let mut state2 = images::EffectiveGState::default();
state2.merge_dict(&dict2, &doc, None);
state2.merge_dict(&dict2, &doc, b"GS2");
assert!(state1.visually_differs(&state2), "two different inline soft masks must visually differ");
}
#[test]
fn cid_to_gid_map_odd_length_is_trimmed_to_even() {
let mut m_bytes = vec![0u8, 1u8, 0u8, 2u8, 0u8];
let needed_len = 5;
if m_bytes.len() > needed_len {
m_bytes.truncate(needed_len);
}
if m_bytes.len() % 2 != 0 {
m_bytes.pop();
}
assert_eq!(m_bytes.len(), 4);
assert_eq!(m_bytes.len() % 2, 0);
}
#[test]
fn is_entire_page_wrapped_with_nested_scopes() {
let wrapped = "q 1 0 0 1 0 0 cm q 0 0 10 10 re f Q 200 0 0 100 100 100 cm /Im0 Do Q";
@@ -9840,7 +9834,7 @@ fn crop_image_with_leading_cm_in_scope_places_clip_before_internal_cm() {
let mut doc = image_page_with(content);
let before = images::page_images(&doc, 0).unwrap();
assert_eq!(before.len(), 1);
images::crop_image(&mut doc, 0, 0, [50.0, 50.0, 55.0, 55.0]).unwrap();
crop(&mut doc, 0, 0, [50.0, 50.0, 55.0, 55.0]).unwrap();
let doc = reopen(&doc);
let after = images::page_images(&doc, 0).unwrap();
assert_eq!(after.len(), 1);
@@ -9857,7 +9851,7 @@ fn remove_crop_preserves_cm_before_extgstate() {
let mut doc = image_page_with(content);
let before = images::page_images(&doc, 0).unwrap();
assert_eq!(before.len(), 1);
images::remove_image_crop(&mut doc, 0, 0).unwrap();
remove_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let bytes = page_content_bytes(&doc, 0);
let s = String::from_utf8_lossy(&bytes);