Merge origin/main into feat/jbig2-run-allowance

This commit is contained in:
FredApps
2026-11-01 21:06:29 +01:00
173 changed files with 17978 additions and 1673 deletions
+1 -2
View File
@@ -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(),
+7 -1
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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)?;
+5 -1
View File
@@ -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};
+68
View File
@@ -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);
}
}
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+272
View File
@@ -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));
}
}