mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:20:32 -06:00
Merge origin/main into feat/jbig2-run-allowance
This commit is contained in:
@@ -18,9 +18,8 @@ impl Document {
|
||||
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
|
||||
pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option<usize> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
|
||||
self.page_images(page).iter().rposition(|i| {
|
||||
inside(&i.rect)
|
||||
i.contains(at)
|
||||
&& match (i.is_form, i.object) {
|
||||
(false, Some(_)) => true,
|
||||
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
|
||||
/// A change to an existing page image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -20,6 +20,12 @@ pub enum ImageEdit {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
Delete,
|
||||
/// Affine transform [a, b, c, d, e, f] applied to the image's matrix.
|
||||
Transform([f64; 6]),
|
||||
/// Change the stacking order.
|
||||
Arrange(ArrangeOrder),
|
||||
/// Remove any private crop on the image.
|
||||
RemoveCrop,
|
||||
}
|
||||
|
||||
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
|
||||
|
||||
@@ -111,10 +111,17 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Move object",
|
||||
"Move objects",
|
||||
"Move image",
|
||||
"Transform image",
|
||||
"Rotate image",
|
||||
"Crop image",
|
||||
"Flip image",
|
||||
"Replace image",
|
||||
"Delete image",
|
||||
"Bring to front",
|
||||
"Bring forward",
|
||||
"Send backward",
|
||||
"Send to back",
|
||||
"Remove crop",
|
||||
"Add header & footer",
|
||||
"Update header & footer",
|
||||
"Add watermark",
|
||||
@@ -254,11 +261,19 @@ impl Edit {
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
ImageEdit::Move(_) => "Move image",
|
||||
ImageEdit::Transform(_) => "Transform image",
|
||||
ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image",
|
||||
ImageEdit::Crop(_) => "Crop image",
|
||||
ImageEdit::Flip { .. } => "Flip image",
|
||||
ImageEdit::Replace { .. } => "Replace image",
|
||||
ImageEdit::Delete => "Delete image",
|
||||
ImageEdit::Arrange(ord) => match ord {
|
||||
pdfcraft_edit::ArrangeOrder::BringToFront => "Bring to front",
|
||||
pdfcraft_edit::ArrangeOrder::BringForward => "Bring forward",
|
||||
pdfcraft_edit::ArrangeOrder::SendBackward => "Send backward",
|
||||
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
|
||||
},
|
||||
ImageEdit::RemoveCrop => "Remove crop",
|
||||
}),
|
||||
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
|
||||
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
|
||||
|
||||
+165
-34
@@ -4,6 +4,7 @@ use super::{
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
use pdfcraft_content::Matrix;
|
||||
|
||||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||||
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
|
||||
@@ -250,6 +251,169 @@ impl EditCtx {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> {
|
||||
match change {
|
||||
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
|
||||
}
|
||||
ImageEdit::Transform(t) => {
|
||||
let [a, b, c, d, _, _] = *t;
|
||||
let determinant = a * d - b * c;
|
||||
if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into())));
|
||||
}
|
||||
}
|
||||
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let img = pdfcraft_edit::page_images(doc, page)?
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
|
||||
let c = match change {
|
||||
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
|
||||
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
|
||||
ImageEdit::Rotate(q) => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
|
||||
}
|
||||
ImageEdit::RotateBy(degrees) => {
|
||||
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
|
||||
pdfcraft_edit::ImageChange::Transform(t)
|
||||
}
|
||||
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
|
||||
img.visible_rect.unwrap_or(img.rect),
|
||||
0,
|
||||
*horizontal,
|
||||
!*horizontal,
|
||||
)),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into())));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
|
||||
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
|
||||
};
|
||||
let page_model = pdfcraft_model::pages(doc)
|
||||
.into_iter()
|
||||
.nth(page)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
|
||||
let target_stream_obj = match page_model.dict.get(b"Contents") {
|
||||
None => None,
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
|
||||
_ if img.stream == 0 => c.as_ref(),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
|
||||
let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect();
|
||||
// Ownership belongs to the drawing stream, not the XObject: original and added images may
|
||||
// show the same resource without sharing an editable item.
|
||||
let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t));
|
||||
let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a)));
|
||||
|
||||
let mut ai_to_update = None;
|
||||
if let Some((added_idx, added_item)) = matched_added
|
||||
&& let AddedContent::Image(ai) = added_item.content.clone()
|
||||
{
|
||||
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|
||||
|| img.visible_rect.is_some_and(|v| {
|
||||
(v[0] - img.rect[0]).abs() > 1e-4
|
||||
|| (v[1] - img.rect[1]).abs() > 1e-4
|
||||
|| (v[2] - img.rect[2]).abs() > 1e-4
|
||||
|| (v[3] - img.rect[3]).abs() > 1e-4
|
||||
});
|
||||
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
|
||||
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
|
||||
let should_promote = has_active_crop
|
||||
|| non_axis_transform
|
||||
|| matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete);
|
||||
|
||||
if should_promote {
|
||||
doc.update_dict(added_item.obj, |d| {
|
||||
d.remove(b"PCAdded");
|
||||
d.remove(b"PCMark");
|
||||
})
|
||||
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
|
||||
} else {
|
||||
ai_to_update = Some((*added_idx, ai));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((added_idx, mut ai)) = ai_to_update {
|
||||
let view = Matrix(page_model.view_matrix(doc));
|
||||
let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?;
|
||||
|
||||
match change {
|
||||
ImageEdit::Move(to) => {
|
||||
ai.rect = to_display.bbox(*to);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Transform(_) | ImageEdit::Flip { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Transform(t) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into())));
|
||||
};
|
||||
let t_display = view.then(&Matrix(t)).then(&to_display);
|
||||
let [ta, tb, tc, td, _, _] = t_display.0;
|
||||
let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5;
|
||||
let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5;
|
||||
|
||||
let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel {
|
||||
(0, ta < 0.0, td < 0.0)
|
||||
} else if is_perpendicular {
|
||||
match (tb > 0.0, tc > 0.0) {
|
||||
(true, false) => (1, false, false),
|
||||
(false, true) => (3, false, false),
|
||||
(true, true) => (1, false, true),
|
||||
(false, false) => (1, true, false),
|
||||
}
|
||||
} else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"non-axis-aligned transforms are not supported for added images".into(),
|
||||
)));
|
||||
};
|
||||
let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) };
|
||||
ai.flip_h ^= toggle_h;
|
||||
ai.flip_v ^= toggle_v;
|
||||
ai.rotation = (ai.rotation + quarters_ccw) % 4;
|
||||
ai.rect = t_display.bbox(ai.rect);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Rotate(q) => {
|
||||
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
|
||||
ai.rotation = (ai.rotation + k) % 4;
|
||||
let r = ai.rect;
|
||||
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
if k % 2 == 1 {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
|
||||
}
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Replace { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
|
||||
};
|
||||
ai.image = new_image;
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
|
||||
pdfcraft_edit::change_image(doc, page, index, &c)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pdfcraft_edit::change_image(doc, page, index, &c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform an edit on a working copy (the caller discards it on error).
|
||||
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
match edit {
|
||||
@@ -525,40 +689,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::AddOcrText { page, words } => {
|
||||
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
|
||||
}
|
||||
Edit::EditPageImage { page, index, change } => {
|
||||
let img = pdfcraft_edit::page_images(doc, *page)?
|
||||
.into_iter()
|
||||
.nth(*index)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
|
||||
let c = match change {
|
||||
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
|
||||
ImageEdit::Rotate(q) => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
|
||||
}
|
||||
ImageEdit::RotateBy(degrees) => {
|
||||
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
|
||||
pdfcraft_edit::ImageChange::Transform(t)
|
||||
}
|
||||
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
|
||||
img.visible_rect.unwrap_or(img.rect),
|
||||
0,
|
||||
*horizontal,
|
||||
!*horizontal,
|
||||
)),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"grouped Form artwork cannot be replaced with a bitmap".into(),
|
||||
)));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
}
|
||||
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
|
||||
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
|
||||
Edit::EditTextBlock { page, block, text, style } => {
|
||||
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
|
||||
|
||||
@@ -54,6 +54,7 @@ mod stamp_preview;
|
||||
mod test_hooks;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
pub mod text_script;
|
||||
pub mod text_style_bookmarks;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
@@ -64,6 +65,7 @@ use std::sync::Arc;
|
||||
pub use links::{
|
||||
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
};
|
||||
pub use pdfcraft_edit::strip_subset_tag;
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::{CommentLayer, StampPreview};
|
||||
@@ -137,7 +139,7 @@ pub use history::Label;
|
||||
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;
|
||||
pub use pictures::{MarkFile, image_dimensions};
|
||||
use pictures::{embed_image, fit_picture, mark_source};
|
||||
use protection::unrestricted;
|
||||
pub use protection::{Changes, Printing, Protection, SecuritySummary};
|
||||
@@ -154,6 +156,8 @@ use xfa_form::{
|
||||
mod clock;
|
||||
mod doc_state;
|
||||
|
||||
pub use pdfcraft_edit::ArrangeOrder;
|
||||
|
||||
use clock::{local_utc_offset, parse_ymd};
|
||||
pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files};
|
||||
use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::ImageDecoder;
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
|
||||
@@ -57,3 +59,69 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(64_000_000 * 4);
|
||||
reader.limits(limits);
|
||||
let mut decoder = reader.into_decoder().ok()?;
|
||||
let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
if format == image::ImageFormat::Jpeg
|
||||
&& u64::from(w) * u64::from(h) <= (64 << 20)
|
||||
&& let Ok(orientation) = decoder.orientation()
|
||||
&& matches!(
|
||||
orientation,
|
||||
image::metadata::Orientation::Rotate90
|
||||
| image::metadata::Orientation::Rotate270
|
||||
| image::metadata::Orientation::Rotate90FlipH
|
||||
| image::metadata::Orientation::Rotate270FlipH
|
||||
)
|
||||
{
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg");
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() {
|
||||
let turned_6 = phone_jpeg(60, 20, 6);
|
||||
assert_eq!(image_dimensions(&turned_6), Some((20, 60)));
|
||||
|
||||
let turned_8 = phone_jpeg(60, 20, 8);
|
||||
assert_eq!(image_dimensions(&turned_8), Some((20, 60)));
|
||||
|
||||
let upright_1 = phone_jpeg(60, 20, 1);
|
||||
assert_eq!(image_dimensions(&upright_1), Some((60, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inputs_return_none() {
|
||||
assert_eq!(image_dimensions(&[]), None);
|
||||
assert_eq!(image_dimensions(b"not an image"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ use super::{
|
||||
};
|
||||
use crate::{EditNotice, MarkKind};
|
||||
|
||||
/// Whether the edit, or any edit inside a batch, deletes pages.
|
||||
fn deletes_pages(edit: &Edit) -> bool {
|
||||
match edit {
|
||||
Edit::DeletePages { .. } => true,
|
||||
Edit::Batch { edits, .. } => edits.iter().any(deletes_pages),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
|
||||
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
|
||||
@@ -163,6 +172,11 @@ impl Session {
|
||||
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
|
||||
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
|
||||
// So does any edit to a file rebuilt from object headers: it has no chain to append to.
|
||||
// Deleted pages remain in earlier incremental revisions (#3407), so rewrite
|
||||
// unsigned files in full. Signed files must retain their signed bytes.
|
||||
if !signed && deletes_pages(&edit) {
|
||||
next.require_full_save();
|
||||
}
|
||||
let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed();
|
||||
if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) {
|
||||
return Err(EditError::SignedRewrite(edit.label().to_string()));
|
||||
@@ -399,6 +413,7 @@ impl Session {
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
|
||||
});
|
||||
let touches_marks = edit.is_none_or(|e| {
|
||||
|
||||
+619
-16
@@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
objs.push(b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec());
|
||||
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
|
||||
let mut page_dict = String::from("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >>");
|
||||
if rotate != 0 {
|
||||
page_dict.push_str(&format!(" /Rotate {rotate}"));
|
||||
}
|
||||
if (user_unit - 1.0).abs() > 1e-6 {
|
||||
page_dict.push_str(&format!(" /UserUnit {user_unit}"));
|
||||
}
|
||||
page_dict.push_str(" >>");
|
||||
objs.push(page_dict.into_bytes());
|
||||
let body = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
|
||||
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn session_with(n: usize) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
|
||||
@@ -934,9 +967,10 @@ fn revert_preserves_the_saved_pages_after_undo() {
|
||||
#[test]
|
||||
fn full_save_is_readable_and_smaller_after_deletes() {
|
||||
let (mut s, id) = session_with(5);
|
||||
let original = s.save_bytes(id).unwrap();
|
||||
s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap();
|
||||
let full = s.save_full_bytes(id).unwrap();
|
||||
assert!(full.len() < s.save_bytes(id).unwrap().len());
|
||||
assert!(full.len() < original.len());
|
||||
let mut s2 = Session::new();
|
||||
let id2 = s2.open("full.pdf", None, full, None).unwrap();
|
||||
assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]);
|
||||
@@ -6158,24 +6192,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() {
|
||||
/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value
|
||||
/// read the whole form again (twice), so the time grew with the square of the fields. Eight
|
||||
/// times the fields now takes at most about eight times as long (it took about fifty).
|
||||
///
|
||||
/// Each round opens the small form and then the large one, so a busy spell on a shared machine
|
||||
/// slows both, and the best round's ratio is checked. Timing every small opening before the
|
||||
/// large ones let such a spell fall on the large ones alone.
|
||||
#[test]
|
||||
fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() {
|
||||
let time = |n: usize| {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_default()
|
||||
let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100));
|
||||
let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800));
|
||||
let time = |bytes: &Arc<Vec<u8>>| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
};
|
||||
let small = time(100);
|
||||
let large = time(800);
|
||||
let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6);
|
||||
assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)");
|
||||
// The first openings also pay for what the process sets up once.
|
||||
time(&small);
|
||||
time(&large);
|
||||
let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect();
|
||||
let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6);
|
||||
let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min);
|
||||
assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)");
|
||||
}
|
||||
|
||||
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
|
||||
@@ -8101,6 +8138,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(rgba, px.into_raw());
|
||||
assert!(d.page_image_rgba(0, 1).is_err());
|
||||
|
||||
// When the image is cropped, page_image_at only hit-tests the visible cropped region:
|
||||
let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
|
||||
assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive");
|
||||
}
|
||||
|
||||
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
|
||||
@@ -9297,6 +9341,474 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
|
||||
assert_eq!(truncated, [0]);
|
||||
}
|
||||
|
||||
/// Moving an added image, then rotating it, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_move_then_rotate_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
|
||||
let moved = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(moved), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Transforming an added image, then rotating it, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_transform_then_rotate_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
// Translate by (100, 100): matrix [1, 0, 0, 1, 100, 100]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 100.0, 100.0]) }).unwrap();
|
||||
let transformed = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(transformed), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Moving an added image, then rotating by degrees, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_move_then_rotate_by_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
|
||||
let moved = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::RotateBy(90.0) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(moved), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate_by moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Arranging an added image moves it into the document stream and converts it to an ordinary
|
||||
/// page image (dropping the PCAdded marker), so no empty shell remains in list_added.
|
||||
#[test]
|
||||
fn added_image_arrange_converts_to_ordinary_image() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p1.png".into(), bytes: Arc::new(png.clone()) }).unwrap();
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 200.0]), name: "p2.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert_eq!(s.get(id).unwrap().added().len(), 2);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 1, change: ImageEdit::Arrange(ArrangeOrder::SendToBack) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert_eq!(s.get(id).unwrap().added().len(), 1, "arranged image is converted and no longer an added-item shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_image_crop_then_move_keeps_crop() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 100.0, 100.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([30.0, 30.0, 70.0, 70.0]) }).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([50.0, 50.0, 130.0, 130.0]) }).unwrap();
|
||||
let img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert_eq!(img.visible_rect, Some([60.0, 60.0, 100.0, 100.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_image_move_and_transform_on_rotated_and_user_unit_pages() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let png_bytes = Arc::new(png);
|
||||
|
||||
let cases = [(90, 1.0), (180, 1.0), (270, 1.0), (0, 2.0), (90, 2.0)];
|
||||
|
||||
for (rotate, user_unit) in cases {
|
||||
let pdf = fixture_with_rotation_and_user_unit(rotate, user_unit);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("test.pdf", None, Arc::new(pdf), None).expect("opens");
|
||||
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "img.png".into(), bytes: png_bytes.clone() }).unwrap();
|
||||
|
||||
// Target user-space rectangle R
|
||||
let r = [30.0, 40.0, 80.0, 90.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(r) }).unwrap();
|
||||
|
||||
let painted = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(painted[i] - r[i]).abs() < 1e-3,
|
||||
"Move: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
|
||||
painted[i],
|
||||
r[i]
|
||||
);
|
||||
}
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1);
|
||||
let stream_obj = match &*cos.get(added[0].obj) {
|
||||
pdfcraft_cos::Object::Stream(s) => s.clone(),
|
||||
other => panic!("expected stream, got {other:?}"),
|
||||
};
|
||||
let pc_rect = stream_obj
|
||||
.dict
|
||||
.get(b"PCAdded")
|
||||
.and_then(|o| cos.resolve(o).as_dict().cloned())
|
||||
.and_then(|d| d.get(b"Rect").cloned())
|
||||
.and_then(|o| cos.resolve(&o).as_array().cloned())
|
||||
.unwrap();
|
||||
let pc_box: [f64; 4] =
|
||||
[pc_rect[0].as_f64().unwrap(), pc_rect[1].as_f64().unwrap(), pc_rect[2].as_f64().unwrap(), pc_rect[3].as_f64().unwrap()];
|
||||
let content = String::from_utf8(stream_obj.decoded().unwrap()).unwrap();
|
||||
let re_idx = content.find(" re W n").expect("re W n in content stream");
|
||||
let prefix = &content[..re_idx];
|
||||
let nums: Vec<f64> =
|
||||
prefix.split_whitespace().rev().take(4).collect::<Vec<_>>().into_iter().rev().map(|val| val.parse::<f64>().unwrap()).collect();
|
||||
assert_eq!(nums.len(), 4);
|
||||
let (rx, ry, rw, rh) = (nums[0], nums[1], nums[2], nums[3]);
|
||||
assert!((rx - pc_box[0]).abs() < 1e-3, "re x {rx} != pc_box[0] {}", pc_box[0]);
|
||||
assert!((ry - pc_box[1]).abs() < 1e-3, "re y {ry} != pc_box[1] {}", pc_box[1]);
|
||||
assert!((rw - (pc_box[2] - pc_box[0])).abs() < 1e-3, "re w {rw} != {}", pc_box[2] - pc_box[0]);
|
||||
assert!((rh - (pc_box[3] - pc_box[1])).abs() < 1e-3, "re h {rh} != {}", pc_box[3] - pc_box[1]);
|
||||
|
||||
// Transform test: user-space translation by (10, 10)
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 10.0]) }).unwrap();
|
||||
|
||||
let expected_transformed = [r[0] + 10.0, r[1] + 10.0, r[2] + 10.0, r[3] + 10.0];
|
||||
let painted_transformed = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(painted_transformed[i] - expected_transformed[i]).abs() < 1e-3,
|
||||
"Transform: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
|
||||
painted_transformed[i],
|
||||
expected_transformed[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_on_added_image_replaces_xobject_and_undo_restores() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([255, 0, 0]);
|
||||
let png2 = make_png([0, 0, 255]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
let old_obj = s.get(id).unwrap().page_images(0)[0].object.expect("initial image object ref");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img2.png".into(), bytes: png2 } }).unwrap();
|
||||
|
||||
let new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("new image object ref");
|
||||
assert_ne!(new_obj, old_obj, "after Replace the page references the new XObject");
|
||||
|
||||
s.undo(id).unwrap();
|
||||
|
||||
let restored_obj = s.get(id).unwrap().page_images(0)[0].object.expect("restored image object ref");
|
||||
assert_eq!(restored_obj, old_obj, "undo restores the old XObject");
|
||||
|
||||
// Also test replacement on an image with active crop (promoted path)
|
||||
let png3 = make_png([0, 255, 0]);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([25.0, 25.0, 55.0, 55.0]) }).unwrap();
|
||||
let crop_obj = s.get(id).unwrap().page_images(0)[0].object.expect("crop image object ref");
|
||||
assert_eq!(crop_obj, old_obj);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img3.png".into(), bytes: png3 } }).unwrap();
|
||||
let promoted_new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("promoted replace object ref");
|
||||
assert_ne!(promoted_new_obj, crop_obj);
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0)[0].object.expect("undo promoted replace"), crop_obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([255, 0, 0]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 80.0, 40.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
let initial_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert_eq!(initial_img.rect, [0.0, 0.0, 80.0, 40.0]);
|
||||
assert!(initial_img.matrix[0] > 0.0, "initially unflipped");
|
||||
|
||||
// Mirroring transform: [-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
|
||||
|
||||
let flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(flipped_img.matrix[0] < 0.0, "horizontal mirror flips matrix x-axis, got matrix: {:?}", flipped_img.matrix);
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1);
|
||||
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
|
||||
assert!(ai.flip_h, "PCAdded record must have flip_h = true");
|
||||
|
||||
// Mirroring horizontally again flips it back
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
|
||||
let unflipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(unflipped_img.matrix[0] > 0.0, "double horizontal mirror restores positive x-axis");
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
|
||||
assert!(!ai2.flip_h, "PCAdded record must have flip_h = false");
|
||||
|
||||
// Mirroring vertically: [1.0, 0.0, 0.0, -1.0, 0.0, 40.0]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, -1.0, 0.0, 40.0]) }).unwrap();
|
||||
let v_flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(v_flipped_img.matrix[3] < 0.0, "vertical mirror flips matrix y-axis, got matrix: {:?}", v_flipped_img.matrix);
|
||||
let cos3 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai3) = &pdfcraft_edit::list_added(&cos3)[0].content else { panic!("expected added image") };
|
||||
assert!(ai3.flip_v, "PCAdded record must have flip_v = true");
|
||||
|
||||
// Non-finite transform must be refused with error
|
||||
let res = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]) });
|
||||
assert!(res.is_err(), "NaN transform must be refused");
|
||||
|
||||
let res_inf = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, f64::INFINITY, 0.0, 0.0]) });
|
||||
assert!(res_inf.is_err(), "infinite transform must be refused");
|
||||
|
||||
let res_move = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([f64::NAN, 20.0, 100.0, 60.0]) });
|
||||
assert!(res_move.is_err(), "NaN position must be refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_on_added_image_folds_quarter_turns_and_promotes_non_axis_aligned() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([0, 128, 255]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
// 1. A pure 90° clockwise rotation matrix about the image centre:
|
||||
let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap();
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1, "stays as PCAdded image");
|
||||
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
|
||||
assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)");
|
||||
// Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175]
|
||||
assert!((ai.rect[0] - 125.0).abs() < 1e-4);
|
||||
assert!((ai.rect[1] - 75.0).abs() < 1e-4);
|
||||
assert!((ai.rect[2] - 175.0).abs() < 1e-4);
|
||||
assert!((ai.rect[3] - 175.0).abs() < 1e-4);
|
||||
|
||||
// 2. A pure 90° counter-clockwise rotation matrix:
|
||||
let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap();
|
||||
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
|
||||
assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0");
|
||||
assert!((ai2.rect[0] - 100.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[1] - 100.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[2] - 200.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[3] - 150.0).abs() < 1e-4);
|
||||
|
||||
// Arbitrary transforms promote the added image, just like RotateBy.
|
||||
let initial_matrix = s.get(id).unwrap().page_images(0)[0].matrix;
|
||||
let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
assert!(pdfcraft_edit::list_added(&cos).is_empty(), "arbitrary transform promotes the image");
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
|
||||
let expected = pdfcraft_content::Matrix(initial_matrix).then(&pdfcraft_content::Matrix(rot_45)).0;
|
||||
let actual = s.get(id).unwrap().page_images(0)[0].matrix;
|
||||
assert!(actual.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3), "{actual:?} != {expected:?}");
|
||||
s.undo(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
assert_eq!(pdfcraft_edit::list_added(&cos).len(), 1, "undo restores added-image editability");
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.5, 0.0, 1.0, 0.0, 0.0]) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_added_image_transform_reflections_with_rotation() {
|
||||
let make_asymmetric_png = || {
|
||||
let mut px = Vec::with_capacity(20 * 20 * 3);
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let color = match (x < 10, y < 10) {
|
||||
(true, true) => [255, 0, 0], // TL: Red
|
||||
(false, true) => [0, 255, 0], // TR: Green
|
||||
(true, false) => [0, 0, 255], // BL: Blue
|
||||
(false, false) => [255, 255, 0], // BR: Yellow
|
||||
};
|
||||
px.extend_from_slice(&color);
|
||||
}
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 20, 20);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&px).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
|
||||
let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
let i = ((y_from_top * out.width + x) * 4) as usize;
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
|
||||
};
|
||||
|
||||
let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50;
|
||||
let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50;
|
||||
let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200;
|
||||
let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50;
|
||||
|
||||
let check_quadrants =
|
||||
|s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| {
|
||||
let c_tl = colour_at(s, id, 75, 125);
|
||||
let c_tr = colour_at(s, id, 125, 125);
|
||||
let c_bl = colour_at(s, id, 75, 175);
|
||||
let c_br = colour_at(s, id, 125, 175);
|
||||
assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl);
|
||||
assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr);
|
||||
assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl);
|
||||
assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br);
|
||||
};
|
||||
|
||||
// --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green.
|
||||
{
|
||||
// 1a: Flip Horizontal after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 1b: Flip Vertical after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical");
|
||||
}
|
||||
|
||||
// --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue.
|
||||
{
|
||||
// 2a: Flip Horizontal after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 2b: Flip Vertical after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical");
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
|
||||
/// field the file leaves to the viewer. The page as displayed and printed as an image shows the
|
||||
/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in
|
||||
@@ -9588,3 +10100,94 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes()
|
||||
s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap();
|
||||
black(&s, rewritten);
|
||||
}
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_after_deleting_pages_rewrites_the_file_without_them() {
|
||||
let (mut s, id) = session_with(6);
|
||||
let original = s.save_bytes(id).unwrap();
|
||||
s.apply(id, Edit::DeletePages { pages: vec![1, 2, 3, 4] }).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.len() < original.len(), "the file shrank: {} -> {}", original.len(), saved.len());
|
||||
assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file");
|
||||
assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision");
|
||||
let mut again = Session::new();
|
||||
let id2 = again.open("saved.pdf", None, saved, None).unwrap();
|
||||
assert_eq!(page_texts(&again, id2), ["Page 1", "Page 6"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_after_deleting_pages_from_a_signed_file_stays_incremental() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(3)), None).unwrap();
|
||||
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() };
|
||||
let signed = s.sign(id, &digital_id, opts).unwrap();
|
||||
s.mark_signed(id, signed.clone(), None).unwrap();
|
||||
s.apply(id, Edit::DeletePages { pages: vec![2] }).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(signed.as_ref()), "the signed bytes stay an incremental update");
|
||||
assert!(s.get(id).unwrap().is_signed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_after_a_batch_that_deletes_pages_rewrites_the_file() {
|
||||
let (mut s, id) = session_with(6);
|
||||
let original = s.save_bytes(id).unwrap();
|
||||
let batch = Edit::Batch {
|
||||
label: "Delete and rotate".into(),
|
||||
edits: vec![
|
||||
Edit::Batch { label: "Delete pages".into(), edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }] },
|
||||
Edit::RotatePages { pages: vec![0], degrees: 90 },
|
||||
],
|
||||
};
|
||||
s.apply(id, batch).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file");
|
||||
assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_without_deleting_pages_stays_incremental() {
|
||||
let (mut s, id) = session_with(3);
|
||||
let original = s.save_bytes(id).unwrap();
|
||||
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_image_edit_does_not_match_an_added_sibling_of_the_same_xobject() {
|
||||
let mut document = pdfcraft_cos::Document::open(Arc::new(jpeg_cover_pdf(650))).unwrap();
|
||||
let original = pdfcraft_edit::page_images(&document, 0).unwrap()[0].clone();
|
||||
let added = AddedImage::new([20.0, 30.0, 80.0, 90.0], original.object.unwrap());
|
||||
pdfcraft_edit::add_content(&mut document, 0, &AddedContent::Image(added.clone())).unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&document, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let mut session = Session::new();
|
||||
let id = session.open("shared-image.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let before_added = session.get(id).unwrap().added()[0].content.clone();
|
||||
session.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([40.0, 50.0, 140.0, 150.0]) }).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
assert_eq!(doc.added()[0].content, before_added, "matching an original Do must not select the added sibling's metadata");
|
||||
assert_eq!(doc.page_images(0)[0].rect, [40.0, 50.0, 140.0, 150.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_transform_engine_refuses_singular_matrices_without_mutation() {
|
||||
let mut png = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut png, 1, 1);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[100, 150, 200]).unwrap();
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_images(&[("pixel.png".into(), png)]).unwrap();
|
||||
let id = s.open("image.pdf", None, bytes, None).unwrap();
|
||||
for matrix in [[0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0 + 1e-12, 0.0, 0.0], [1e308, 0.0, 0.0, 1e308, 0.0, 0.0]] {
|
||||
let before = s.get(id).unwrap().bytes.clone();
|
||||
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(matrix) }).is_err());
|
||||
assert_eq!(s.get(id).unwrap().bytes, before);
|
||||
}
|
||||
}
|
||||
|
||||
+190
-10
@@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
|
||||
/// Bounded scan with resolved roots and no descendant symlinks.
|
||||
/// Only standalone, openly licensed, embeddable faces are listed.
|
||||
/// The caller controls roots (automation resolves confinement before calling this).
|
||||
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
discover_budgeted(roots, 64u64 << 20)
|
||||
}
|
||||
|
||||
/// Bounded scan with resolved roots and no descendant symlinks. `budget` caps the total
|
||||
/// size of the fonts listed: a rejected file (unreadable, not openly licensed, a duplicate)
|
||||
/// does not spend it. Every file read
|
||||
/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan.
|
||||
pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec<FontFile> {
|
||||
use std::io::Read;
|
||||
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
|
||||
// User font directories themselves are often links. Resolve only the explicitly
|
||||
// supplied roots; links found below them must not escape an automation read root.
|
||||
let mut pending: Vec<_> = roots.iter().take(16).filter_map(|r| r.canonicalize().ok()).map(|r| (r, 0usize)).collect();
|
||||
let mut files = Vec::new();
|
||||
let mut entries = 0usize;
|
||||
let mut budget = 64u64 << 20;
|
||||
let mut read_allowance = budget.saturating_mul(4);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen_face_content = std::collections::HashSet::new();
|
||||
while let Some((path, depth)) = pending.pop() {
|
||||
if entries >= 4096 || budget == 0 {
|
||||
if entries >= 4096 || budget == 0 || read_allowance == 0 {
|
||||
break;
|
||||
}
|
||||
entries += 1;
|
||||
@@ -53,26 +65,194 @@ pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
if meta.is_dir() && depth < 4 {
|
||||
if let Ok(children) = std::fs::read_dir(&path) {
|
||||
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
|
||||
pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1)));
|
||||
// Best effort: unreadable entries are skipped and do not count against the entry allowance.
|
||||
let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect();
|
||||
sorted_children.sort();
|
||||
pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1)));
|
||||
}
|
||||
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
|
||||
if meta.len() > budget {
|
||||
if meta.len() > budget || meta.len() > read_allowance {
|
||||
continue;
|
||||
}
|
||||
budget = budget.saturating_sub(meta.len());
|
||||
read_allowance = read_allowance.saturating_sub(meta.len());
|
||||
let mut bytes = Vec::new();
|
||||
let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes));
|
||||
if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
|
||||
let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes));
|
||||
if read.is_err() || bytes.len() != meta.len() as usize {
|
||||
continue;
|
||||
}
|
||||
if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
|
||||
files.push(FontFile { path, family, face });
|
||||
let key = font_content_key(&bytes);
|
||||
if seen_face_content.insert((face.clone(), key)) {
|
||||
budget = budget.saturating_sub(meta.len());
|
||||
files.push(FontFile { path, family, face });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
|
||||
files
|
||||
}
|
||||
|
||||
fn font_content_key(bytes: &[u8]) -> (usize, u64) {
|
||||
use std::hash::{DefaultHasher, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
h.write(bytes);
|
||||
(bytes.len(), h.finish())
|
||||
}
|
||||
fn font_extension(path: &Path) -> bool {
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_linked_font_root_is_scanned_but_descendant_links_are_not() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-links-{}", std::process::id()));
|
||||
let real = root.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
std::fs::copy(&source, real.join("regular.ttf")).unwrap();
|
||||
let link = root.join("linked");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
std::os::unix::fs::symlink(&real, real.join("cycle")).unwrap();
|
||||
std::os::unix::fs::symlink(source.with_file_name("Inter-Medium.ttf"), real.join("escape.ttf")).unwrap();
|
||||
let found = discover(&[link]);
|
||||
let expected = real.canonicalize().unwrap().join("regular.ttf");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found.first().unwrap().face, "Inter-Regular");
|
||||
assert_eq!(found.first().unwrap().path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_keeps_all_valid_faces_within_its_scan_budget() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-count-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
let bytes = std::fs::read(source).unwrap();
|
||||
for id in 0..501u32 {
|
||||
let mut font = bytes.clone();
|
||||
// Valid standalone fonts with distinct bytes: an unreferenced trailer
|
||||
// does not change their tables or embedding licence.
|
||||
font.extend_from_slice(&id.to_le_bytes());
|
||||
std::fs::write(root.join(format!("font-{id:03}.ttf")), font).unwrap();
|
||||
}
|
||||
let found = discover_budgeted(std::slice::from_ref(&root), (bytes.len() as u64 + 4) * 501);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(found.len(), 501, "do not silently discard accepted fonts after sorting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_installed_directories() {
|
||||
let dirs = installed_directories();
|
||||
assert!(!dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_font_extension() {
|
||||
assert!(font_extension(Path::new("Arial.ttf")));
|
||||
assert!(font_extension(Path::new("Noto.otf")));
|
||||
assert!(font_extension(Path::new("CUSTOM.TTF")));
|
||||
assert!(font_extension(Path::new("ROBOTO.OTF")));
|
||||
assert!(!font_extension(Path::new("document.pdf")));
|
||||
assert!(!font_extension(Path::new("font.woff")));
|
||||
assert!(!font_extension(Path::new("font.woff2")));
|
||||
assert!(!font_extension(Path::new("no_extension")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_font_content_key() {
|
||||
let empty = font_content_key(&[]);
|
||||
assert_eq!(empty.0, 0);
|
||||
|
||||
let a = font_content_key(b"TrueType font sample data bytes");
|
||||
let b = font_content_key(b"TrueType font sample data bytes");
|
||||
let c = font_content_key(b"Different font sample data bytes");
|
||||
|
||||
assert_eq!(a, b, "identical bytes must produce identical keys");
|
||||
assert_ne!(a, c, "differing bytes must produce distinct keys");
|
||||
assert_eq!(a.0, b"TrueType font sample data bytes".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_empty_and_nonexistent_roots() {
|
||||
let empty = discover(&[]);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]);
|
||||
assert!(nonexistent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_deduplicates_non_adjacent_same_content() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id()));
|
||||
let dir_a = root.join("a");
|
||||
let dir_b = root.join("b");
|
||||
let dir_c = root.join("c");
|
||||
let _ = std::fs::create_dir_all(&dir_a);
|
||||
let _ = std::fs::create_dir_all(&dir_b);
|
||||
let _ = std::fs::create_dir_all(&dir_c);
|
||||
|
||||
let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf");
|
||||
|
||||
if inter_reg.exists() && inter_med.exists() {
|
||||
let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf"));
|
||||
let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf"));
|
||||
let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf"));
|
||||
|
||||
let discovered = discover(std::slice::from_ref(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated");
|
||||
let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect();
|
||||
assert!(faces.contains(&"Inter-Regular"));
|
||||
assert!(faces.contains(&"Inter-Medium"));
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
|
||||
let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len();
|
||||
let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len();
|
||||
|
||||
// An unusable candidate, scanned first (sorted before the real fonts).
|
||||
std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap();
|
||||
std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap();
|
||||
|
||||
// The budget fits Inter-Regular alone; the rejected file must not use it up first.
|
||||
let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"], "a rejected file spends no budget");
|
||||
assert_eq!(faces(&run1), faces(&run2));
|
||||
|
||||
// Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular.
|
||||
std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap();
|
||||
let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
assert_eq!(one.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"]);
|
||||
let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len);
|
||||
assert_eq!(both.len(), 2);
|
||||
|
||||
// Reading stops at four times the budget, whatever was rejected.
|
||||
for i in 0..4 {
|
||||
std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap();
|
||||
}
|
||||
let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and
|
||||
//! `2` ↔ `₂`.
|
||||
//!
|
||||
//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes
|
||||
//! for raised and lowered text, so the font must have those glyphs, and extracted text contains
|
||||
//! them. Characters Unicode has no such form for are left as they are.
|
||||
//!
|
||||
//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`,
|
||||
//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles
|
||||
//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original
|
||||
//! mixture. Restoring that exact prior text requires the caller's edit history (Undo).
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// Raised or lowered text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Each character Unicode raises, and its superscript form.
|
||||
const SUPERSCRIPT: &[(char, char)] = &[
|
||||
('0', '⁰'),
|
||||
('1', '¹'),
|
||||
('2', '²'),
|
||||
('3', '³'),
|
||||
('4', '⁴'),
|
||||
('5', '⁵'),
|
||||
('6', '⁶'),
|
||||
('7', '⁷'),
|
||||
('8', '⁸'),
|
||||
('9', '⁹'),
|
||||
('+', '⁺'),
|
||||
('−', '⁻'),
|
||||
('=', '⁼'),
|
||||
('(', '⁽'),
|
||||
(')', '⁾'),
|
||||
('a', 'ᵃ'),
|
||||
('b', 'ᵇ'),
|
||||
('c', 'ᶜ'),
|
||||
('d', 'ᵈ'),
|
||||
('e', 'ᵉ'),
|
||||
('f', 'ᶠ'),
|
||||
('g', 'ᵍ'),
|
||||
('h', 'ʰ'),
|
||||
('i', 'ⁱ'),
|
||||
('j', 'ʲ'),
|
||||
('k', 'ᵏ'),
|
||||
('l', 'ˡ'),
|
||||
('m', 'ᵐ'),
|
||||
('n', 'ⁿ'),
|
||||
('o', 'ᵒ'),
|
||||
('p', 'ᵖ'),
|
||||
('r', 'ʳ'),
|
||||
('s', 'ˢ'),
|
||||
('t', 'ᵗ'),
|
||||
('u', 'ᵘ'),
|
||||
('v', 'ᵛ'),
|
||||
('w', 'ʷ'),
|
||||
('x', 'ˣ'),
|
||||
('y', 'ʸ'),
|
||||
('z', 'ᶻ'),
|
||||
('A', 'ᴬ'),
|
||||
('B', 'ᴮ'),
|
||||
('D', 'ᴰ'),
|
||||
('E', 'ᴱ'),
|
||||
('G', 'ᴳ'),
|
||||
('H', 'ᴴ'),
|
||||
('I', 'ᴵ'),
|
||||
('J', 'ᴶ'),
|
||||
('K', 'ᴷ'),
|
||||
('L', 'ᴸ'),
|
||||
('M', 'ᴹ'),
|
||||
('N', 'ᴺ'),
|
||||
('O', 'ᴼ'),
|
||||
('P', 'ᴾ'),
|
||||
('R', 'ᴿ'),
|
||||
('T', 'ᵀ'),
|
||||
('U', 'ᵁ'),
|
||||
('V', 'ⱽ'),
|
||||
('W', 'ᵂ'),
|
||||
];
|
||||
|
||||
/// Each character Unicode lowers, and its subscript form.
|
||||
const SUBSCRIPT: &[(char, char)] = &[
|
||||
('0', '₀'),
|
||||
('1', '₁'),
|
||||
('2', '₂'),
|
||||
('3', '₃'),
|
||||
('4', '₄'),
|
||||
('5', '₅'),
|
||||
('6', '₆'),
|
||||
('7', '₇'),
|
||||
('8', '₈'),
|
||||
('9', '₉'),
|
||||
('+', '₊'),
|
||||
('−', '₋'),
|
||||
('=', '₌'),
|
||||
('(', '₍'),
|
||||
(')', '₎'),
|
||||
('a', 'ₐ'),
|
||||
('e', 'ₑ'),
|
||||
('h', 'ₕ'),
|
||||
('i', 'ᵢ'),
|
||||
('j', 'ⱼ'),
|
||||
('k', 'ₖ'),
|
||||
('l', 'ₗ'),
|
||||
('m', 'ₘ'),
|
||||
('n', 'ₙ'),
|
||||
('o', 'ₒ'),
|
||||
('p', 'ₚ'),
|
||||
('r', 'ᵣ'),
|
||||
('s', 'ₛ'),
|
||||
('t', 'ₜ'),
|
||||
('u', 'ᵤ'),
|
||||
('v', 'ᵥ'),
|
||||
('x', 'ₓ'),
|
||||
];
|
||||
|
||||
impl Script {
|
||||
fn pairs(self) -> &'static [(char, char)] {
|
||||
match self {
|
||||
Script::Super => SUPERSCRIPT,
|
||||
Script::Sub => SUBSCRIPT,
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` raised or lowered, or `None` when Unicode has no distinct reversible form for it.
|
||||
/// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision.
|
||||
pub fn form(self, c: char) -> Option<char> {
|
||||
self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form)
|
||||
}
|
||||
|
||||
/// The character `c` is the raised or lowered form of, or `None` when it is no such form.
|
||||
pub fn plain(self, c: char) -> Option<char> {
|
||||
self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text`
|
||||
/// when there is none.
|
||||
fn span(text: &str, range: Option<&Range<usize>>) -> Range<usize> {
|
||||
let Some(range) = range else { return 0..text.len() };
|
||||
let mut start = range.start.min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start = start.saturating_sub(1);
|
||||
}
|
||||
let mut end = range.end.min(text.len());
|
||||
while !text.is_char_boundary(end) {
|
||||
end = end.saturating_add(1);
|
||||
}
|
||||
start..end
|
||||
}
|
||||
|
||||
/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in
|
||||
/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no
|
||||
/// form for (a space, a `q`) don't count toward that state.
|
||||
pub fn is_in(text: &str, range: Option<&Range<usize>>, script: Script) -> bool {
|
||||
let target = text.get(span(text, range)).unwrap_or_default();
|
||||
target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none())
|
||||
}
|
||||
|
||||
/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when
|
||||
/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise
|
||||
/// each character that has a form takes it. Returns the new text and the range the converted
|
||||
/// characters now take, or `None` when nothing changes.
|
||||
///
|
||||
/// Mixed plain/scripted input normalizes to the target script and then to plain text; for
|
||||
/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original
|
||||
/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo.
|
||||
pub fn toggle(text: &str, range: Option<&Range<usize>>, script: Script) -> Option<(String, Range<usize>)> {
|
||||
let span = span(text, range);
|
||||
let target = text.get(span.clone()).filter(|t| !t.is_empty())?;
|
||||
let converted: String = if is_in(text, Some(&span), script) {
|
||||
target.chars().map(|c| script.plain(c).unwrap_or(c)).collect()
|
||||
} else {
|
||||
target.chars().map(|c| script.form(c).unwrap_or(c)).collect()
|
||||
};
|
||||
if converted == target {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(text.len().saturating_add(converted.len()));
|
||||
out.push_str(text.get(..span.start).unwrap_or_default());
|
||||
out.push_str(&converted);
|
||||
out.push_str(text.get(span.end..).unwrap_or_default());
|
||||
let end = span.start.saturating_add(converted.len());
|
||||
Some((out, span.start..end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_form_toggles_back_to_its_character() {
|
||||
for script in [Script::Super, Script::Sub] {
|
||||
for &(plain, form) in script.pairs() {
|
||||
let s = plain.to_string();
|
||||
let (raised, range) = toggle(&s, None, script).unwrap();
|
||||
assert_eq!(raised, form.to_string());
|
||||
assert_eq!(range, 0..raised.len());
|
||||
assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}");
|
||||
}
|
||||
}
|
||||
assert_eq!(toggle("-", None, Script::Super), None);
|
||||
assert_eq!(toggle("-", None, Script::Sub), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_script_round_trips_preserve_both_minus_characters() {
|
||||
for script in [Script::Super, Script::Sub] {
|
||||
let text = "−2 -3";
|
||||
let once = toggle(text, None, script).unwrap().0;
|
||||
assert_eq!(toggle(&once, None, script).unwrap().0, text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_characters_unicode_cannot_raise_toggles_back() {
|
||||
// q has no superscript form and Q no subscript one; the space neither.
|
||||
for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] {
|
||||
let (once, _) = toggle(text, None, script).unwrap();
|
||||
assert_eq!(once, toggled);
|
||||
assert!(is_in(&once, None, script));
|
||||
assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}");
|
||||
}
|
||||
// Nothing to raise: nothing changes.
|
||||
assert_eq!(toggle("q Q", None, Script::Super), None);
|
||||
assert!(!is_in("q Q", None, Script::Super));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_converts_only_its_characters_and_follows_them() {
|
||||
let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap();
|
||||
assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8));
|
||||
assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7));
|
||||
// A mixed range takes the form throughout.
|
||||
assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂");
|
||||
assert!(!is_in("H₂2", None, Script::Sub));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() {
|
||||
for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] {
|
||||
assert!(!is_in(mixed, None, script));
|
||||
let (once, range) = toggle(mixed, None, script).unwrap();
|
||||
assert_eq!(once, uniform);
|
||||
assert_eq!(range, 0..uniform.len());
|
||||
assert!(is_in(&once, None, script));
|
||||
let (twice, range) = toggle(&once, None, script).unwrap();
|
||||
assert_eq!(twice, plain);
|
||||
assert_eq!(range, 0..plain.len());
|
||||
assert_ne!(twice, mixed, "a second toggle normalizes the original mixture");
|
||||
assert_eq!(toggle(&twice, None, script).unwrap().0, uniform);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ranges_are_clamped_to_whole_characters() {
|
||||
// Byte 1 is inside ²: widened to the whole character on each side.
|
||||
assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1));
|
||||
assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None);
|
||||
assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3));
|
||||
assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None);
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 2..1;
|
||||
assert_eq!(toggle("x2", Some(&backwards), Script::Super), None);
|
||||
assert_eq!(toggle("", None, Script::Sub), None);
|
||||
assert!(!is_in("", None, Script::Sub));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user